Compare commits
No commits in common. "main" and "antigravity/a58-agy-eligibility-state" have entirely different histories.
main
...
antigravit
50 changed files with 247 additions and 6351 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -6,22 +6,11 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
# Матрица из двух систем.
|
|
||||||
#
|
|
||||||
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
|
|
||||||
# держался на Windows, а тесты установки и остановки процессов молча
|
|
||||||
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
|
|
||||||
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
|
|
||||||
# проверяются одинаковым набором.
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Clean Runner Test (${{ matrix.os }})
|
name: Clean Windows Runner Test
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: windows-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [windows-latest, ubuntu-latest]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -51,13 +40,9 @@ jobs:
|
||||||
python scripts/release_gate.py
|
python scripts/release_gate.py
|
||||||
|
|
||||||
headless:
|
headless:
|
||||||
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
|
name: Headless Run (no GUI dependencies)
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: windows-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [windows-latest, ubuntu-latest]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
|
||||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
|
|
@ -57,21 +57,6 @@ jobs:
|
||||||
$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8
|
$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8
|
||||||
Write-Host "Generated update_manifest.json with SHA256: $hash"
|
Write-Host "Generated update_manifest.json with SHA256: $hash"
|
||||||
|
|
||||||
# Набор проверяется ДО публикации.
|
|
||||||
#
|
|
||||||
# update_manager ищет в релизе строго HermesHubSetup.exe или
|
|
||||||
# hermes-hub-setup.sh, а шаг выше собирает только zip и манифест. Такой
|
|
||||||
# релиз становится "latest", и любая попытка обновиться отвечает «в
|
|
||||||
# релизе не найден подходящий файл обновления для текущей платформы».
|
|
||||||
#
|
|
||||||
# Раньше это не проявлялось лишь потому, что весь конвейер падал на шаге
|
|
||||||
# Release Gate — на тех же двух дефектах, что и CI; ни один его прогон не
|
|
||||||
# доходил до публикации, а релизы выкладывались мимо него. Как только
|
|
||||||
# тесты позеленели, случайная защита исчезла.
|
|
||||||
- name: Built assets must be installable by the updater
|
|
||||||
run: |
|
|
||||||
python scripts/release_gate.py --assets dist
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|
@ -83,12 +68,3 @@ jobs:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Ворота публикации: проверяют опубликованный релиз, а не сборку.
|
|
||||||
# Релиз есть, ассеты есть, пакет скачан целиком, SHA-256 сошёлся с
|
|
||||||
# опубликованным checksums.txt. Здесь они блокируют: раньше эта проверка
|
|
||||||
# возвращала PASS при обрыве сети, при 404 на манифест и при 404 на
|
|
||||||
# пакет, то есть пропускала релиз при любом исходе.
|
|
||||||
- name: Publication Gate (published release must be verifiable)
|
|
||||||
run: |
|
|
||||||
python scripts/release_gate.py --publication-only
|
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
# Отчёт независимого оркестратора: Release Gate ветки `codex/workflow-canvas` (A30)
|
|
||||||
|
|
||||||
## Дата проведения
|
|
||||||
2026-08-26
|
|
||||||
|
|
||||||
## Объект аудита
|
|
||||||
- **Ветка:** `codex/workflow-canvas`
|
|
||||||
- **Цель:** Независимая проверка реализации задания A30 («Главный экран "Обзор" — граф workflow, файлы агентов, LIVE»).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Сводка Git и состояние репозитория
|
|
||||||
|
|
||||||
- **`START_HEAD` (базовый коммит / merge-base с `main`):** `d6ec34d482a4e00a2017c7b53e934a82df0cc5ad`
|
|
||||||
- **`FINAL_HEAD` (коммит ветки A30):** `0c19738e29683c215352ea9de1b68a0a2e95b1f8` (`feat(web): add workflow canvas and live agent workspace`)
|
|
||||||
- **`origin/main`:** `c35bc4868d62cfa7abb7a1a4c1c17eca51eb6ce5`
|
|
||||||
- **Состояние рабочей директории (`git status`):**
|
|
||||||
- Нестажированные изменения в бинарниках и установщике (`installer/HermesHubSetup.cs`, `launcher/HermesHub.exe`, `launcher/HermesHubWeb.exe`).
|
|
||||||
- Нестажированный фикс CORS в `src/antigravity_provider/router/web/server.py` (перенесённый из `c35bc48` на `main`).
|
|
||||||
- Неотслеживаемые задания в inbox (`agents/inbox/2026-08-25-A32-remove-desktop.md`, `agents/inbox/2026-08-26-antigravity-release-gate-a30.md`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Результаты детерминированных проверок
|
|
||||||
|
|
||||||
### 2.1. Линтер `ruff check .`
|
|
||||||
- **Результат:** `All checks passed!` (0 ошибок, 0 предупреждений).
|
|
||||||
|
|
||||||
### 2.2. Полный регрессионный сьют `pytest tests/ -v`
|
|
||||||
- **Результат:** **458 passed, 2 skipped, 3 deselected, 1 failed** (всего 461 тест).
|
|
||||||
- **Время прогона:** 86.74 сек.
|
|
||||||
|
|
||||||
### 2.3. Скрипт `scripts/release_gate.py`
|
|
||||||
- **Результат:** `[RELEASE GATE: FAILED] One or more checks failed. Release blocked.`
|
|
||||||
- **Причина:** Падение теста обратной совместимости `tests/test_web_parity_a21.py::test_web_client_html_and_js_7_views_parity`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Реестр найденных дефектов
|
|
||||||
|
|
||||||
| ID | Приоритет | Компонент | Описание дефекта и минимальное воспроизведение |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **DEF-01** | **P1** | `tests/test_web_parity_a21.py:133` | **Устаревшая проверка селектора в тестах регрессии.** Тест проверяет наличие старого контейнера `overview-route-diagram` в `index.html`. В рамках A30 главный экран «Обзор» был полностью перестроен в Workflow Canvas (`workflow-canvas`, `workflow-main-layout`), и старый селектор был правомерно удалён из разметки, но тест не был обновлён под новый layout A30. <br>**Воспроизведение:** `pytest tests/test_web_parity_a21.py -k test_web_client_html_and_js_7_views_parity`. |
|
|
||||||
| **DEF-02** | **P2** | `server.py` / `git` | **Отставание ветки от `origin/main`.** Ветка `codex/workflow-canvas` ответвлена от `d6ec34d` и не включает коммит безопасности `c35bc48` (`fix(security): любой сайт во вкладке рядом мог управлять хабом`). Перед финальным слиянием в `main` требуется rebase / merge с актуальным `main`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Результаты проверки подсистем A30
|
|
||||||
|
|
||||||
### P0-1. Модель агента и Agent File
|
|
||||||
- **Статус:** **PASS**
|
|
||||||
- Сервис `WorkflowService` в [`workflow_service.py`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/workflow_service.py) реализует полное управление жизненным циклом агентов: `create_agent`, `update_agent`, `delete_agent`.
|
|
||||||
- Роли роутера автоматически мигрируют в сущности агентов.
|
|
||||||
- Файлы агентов создаются физически на диске в `agents/{role}.md` (например, `agents/orchestrator.md`, `agents/coder-primary.md`) и содержат реальный Markdown.
|
|
||||||
- Удаление агента, задействованного в ребрах графа, требует явного подтверждения (`confirmation_required: True`), предотвращая повреждение графа.
|
|
||||||
- Проверено тестами: `test_router_roles_migrate_to_agents_and_create_real_files`, `test_create_update_file_and_restart_persistence`, `test_delete_requires_explicit_confirmation_when_referenced`.
|
|
||||||
|
|
||||||
### P0-2. Граф workflow (Canvas, EDIT/LIVE, Циклы)
|
|
||||||
- **Статус:** **PASS**
|
|
||||||
- Граф реализован на чистом SVG + HTML5 (без npm, без react, без сторонних зависимостей сборки) в [`workflow.js`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.js) и [`workflow.css`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.css).
|
|
||||||
- **Режимы:** Чёткое переключение между `LIVE` (мониторинг исполнения) и `EDIT` (редактирование графа, соединение портов).
|
|
||||||
- **Редактор ребра:** Модальное окно позволяет задавать условия переходов (`SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`) и подписи.
|
|
||||||
- **Поддержка циклов:** Циклические маршруты (`Кодер 1 → Ревьюер → Кодер 1`) поддержаны и валидируются.
|
|
||||||
- **Защита от бесконечного зацикливания:** Параметр `max_iterations` отображается на экране (например, `Итерация: 2 / 5`), сохраняется в конфигурации и принудительно останавливает цикл с генерацией явного события `WORKFLOW_MAX_ITERATIONS`.
|
|
||||||
- **Элементы управления:** Мини-карта, масштабирование (`- 100% + ⛶`), легенда состояний узлов и рёбер.
|
|
||||||
|
|
||||||
### P0-3. LIVE-мониторинг, события и обработка ошибок
|
|
||||||
- **Статус:** **PASS**
|
|
||||||
- Поддержаны 5 состояний агента: `Ожидает` (серый), `Работает` (синий), `Проверяет` (жёлтый), `Ошибка` (красный), `Завершено` (зелёный).
|
|
||||||
- Тексты реальных ошибок провайдеров (например, `No authentication token found for Codex profile 'codex-orch'`) доходят до статуса запуска и списка событий.
|
|
||||||
- Прерванный перезапуском прогон корректно помечается статусом `interrupted` с записью события `WORKFLOW_INTERRUPTED`.
|
|
||||||
- Проверено тестами: `test_live_cycle_stops_with_explicit_iteration_limit_event`, `test_interrupted_run_is_reported_not_silently_completed`, `test_provider_error_text_reaches_run_and_events`.
|
|
||||||
|
|
||||||
### P0-4. Честность данных (Zero Fake / Zero Mock)
|
|
||||||
- **Статус:** **PASS**
|
|
||||||
- Поиск по кодовой базе показал полное отсутствие захардкоженных демонстрационных чисел из макета (`12`, `3.42 с`, `1.42M`, `94.2%`, `42`, `account-01...`).
|
|
||||||
- Все 5 оперативных KPI-показателей на экране «Обзор» берутся из реальных источников:
|
|
||||||
1. *Активные задачи:* `workflow.run.status` (0 или 1).
|
|
||||||
2. *Агенты онлайн:* `readiness.roles_ready_count / readiness.total_roles` из сервиса `readiness`.
|
|
||||||
3. *Среднее время ответа:* `telemetry.global.latency_p50_ms` (при отсутствии вызовов: `Н/Д: за 24 часа нет измеренных вызовов`).
|
|
||||||
4. *Использование токенов:* `telemetry.global.total_tokens` (при отсутствии: `Н/Д: провайдеры не вернули usage`).
|
|
||||||
5. *Успешность задач:* отношение `successful_calls / total_calls` (при отсутствии: `Н/Д: за 24 часа нет завершённых вызовов`).
|
|
||||||
- Состояния загрузки (`workflow.is_loading`) явно отделены от отсутствия данных.
|
|
||||||
|
|
||||||
### P0-5. Неприкосновенность десктопного UI
|
|
||||||
- **Статус:** **PASS**
|
|
||||||
- Проверка `git diff --stat d6ec34d 0c19738 -- src/antigravity_provider/router/ui` подтвердила **0 изменений** в каталоге `router/ui/**`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Проверка артефактов и скриншотов
|
|
||||||
|
|
||||||
Все 5 обязательных скриншотов присутствуют в каталоге `docs/screenshots/a30/` и проверены:
|
|
||||||
1. [`overview-live.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live.png) — Главный экран в режиме LIVE с 6 агентами, честными статусами «Н/Д» и мини-картой.
|
|
||||||
2. [`overview-edit-inspector.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-edit-inspector.png) — Режим EDIT с выбранным узлом «Кодер 1», портами соединения и панелью инспектора (вкладки Основное, Модель, Инструкции, Инструменты, Память).
|
|
||||||
3. [`edge-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/edge-editor.png) — Модальное окно создания/редактирования ребра (`coder-primary` → `reviewer`, условие `SUCCESS`).
|
|
||||||
4. [`agent-file-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/agent-file-editor.png) — Редактор файла агента `agents/coder-primary.md` с реальным содержимым.
|
|
||||||
5. [`overview-live-provider-error.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live-provider-error.png) — Отображение реальной ошибки провайдера в узле «Главный оркестратор» (красный статус) и в журнале событий LIVE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Пропущенные проверки
|
|
||||||
- **Пропущенных проверок нет.** Все 10 пунктов регламента выполнены в полном объёме.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Итоговый вердикт Release Gate
|
|
||||||
|
|
||||||
> **ВЕРДИКТ: `BLOCKED` (Требуется исправление 1 теста и Rebase)**
|
|
||||||
|
|
||||||
**Обоснование:**
|
|
||||||
1. Функциональная реализация A30 (`WorkflowService`, Canvas, Agent Files, LIVE/EDIT, Cycle limits, Data honesty) выполнена качественно и полностью соответствует ТЗ.
|
|
||||||
2. Автоматический Release Gate заблокирован из-за дефекта **DEF-01** (устаревший ассерт `overview-route-diagram` в `tests/test_web_parity_a21.py:133`), дающего 1 падение из 461 теста.
|
|
||||||
3. Ветка требует rebase на актуальный `origin/main` (включение фикса безопасности CORS **DEF-02**) и обновления теста `test_web_parity_a21.py` на селектор `workflow-canvas`.
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
# Отчёт HUB-1: зелёный main и P0 из аудита
|
|
||||||
|
|
||||||
## Сдача
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Ветка | `hub/audit-p0-green-main` |
|
|
||||||
| `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` |
|
|
||||||
| Последний рабочий коммит | `f061961600dd2eb3d6901cc154dbf5a82456dd14` |
|
|
||||||
| `origin/main` на момент сдачи | `93da1b2` (не двигался) |
|
|
||||||
| PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 |
|
|
||||||
| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525 |
|
|
||||||
| `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) |
|
|
||||||
|
|
||||||
### Зелёный CI — все четыре джоба
|
|
||||||
|
|
||||||
| Джоб | Итог |
|
|
||||||
|---|---|
|
|
||||||
| Clean Runner Test (windows-latest) | **pass** |
|
|
||||||
| Clean Runner Test (ubuntu-latest) | **pass** |
|
|
||||||
| Headless Run (windows-latest) | **pass** |
|
|
||||||
| Headless Run (ubuntu-latest) | **pass** |
|
|
||||||
|
|
||||||
`ruff check .` — чисто. Release Gate — PASSED на обеих системах.
|
|
||||||
|
|
||||||
Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы —
|
|
||||||
739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Зелёный main
|
|
||||||
|
|
||||||
### Обе причины из задания подтвердились — и обе оказались шире описания
|
|
||||||
|
|
||||||
**1. Инвариант A37 не держался на Windows.** Причина именно та, что
|
|
||||||
предполагалась. Воспроизведено локально на окружении Windows (нет переменной
|
|
||||||
`HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь
|
|
||||||
перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается
|
|
||||||
«внутри разрешённого корня» — команда проходит.
|
|
||||||
|
|
||||||
Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же
|
|
||||||
проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`.
|
|
||||||
`Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и
|
|
||||||
удаление системного каталога Windows выглядело работой внутри проекта.
|
|
||||||
|
|
||||||
Разбор пути сведён в один конвейер, как и требовало задание: классификация
|
|
||||||
диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие
|
|
||||||
распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог
|
|
||||||
даже когда их нет в окружении → нормализация разделителей → канонизация →
|
|
||||||
сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает
|
|
||||||
проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер
|
|
||||||
пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`.
|
|
||||||
|
|
||||||
Доказательство: новые тесты воспроизводят окружение обеих систем на любой из
|
|
||||||
них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на
|
|
||||||
зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на
|
|
||||||
Windows-раннере.
|
|
||||||
|
|
||||||
**2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же
|
|
||||||
`UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output`
|
|
||||||
ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать
|
|
||||||
нельзя. Той же реализацией заменён самодельный блок в `cli_commands`.
|
|
||||||
Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`.
|
|
||||||
|
|
||||||
### Причин красного CI было не две, а семь
|
|
||||||
|
|
||||||
Это главное расхождение с заданием. Ревьюер видел две; живой прогон на
|
|
||||||
Windows после их устранения показал ещё пять. Четыре из них — **не дефекты
|
|
||||||
продукта, а допущения тестов, зашитые под Linux**:
|
|
||||||
|
|
||||||
1. `test_a41` читал вывод скрипта в кодировке системы. Скрипт стал писать
|
|
||||||
UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на
|
|
||||||
`UnicodeDecodeError`, оставляя `proc.stdout` равным `None`. Кодировка
|
|
||||||
задана явно с обеих сторон трубы.
|
|
||||||
2. `test_p0_3_stop_running_hub` знал только про ветку Linux (`os.kill` по
|
|
||||||
списку от `pgrep`). На Windows процессы останавливает `taskkill` по списку
|
|
||||||
от `wmic`. Инвариант один — «чужой процесс останавливается, свой PID не
|
|
||||||
трогаем» — теперь проверяется на обеих ветках.
|
|
||||||
3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на
|
|
||||||
Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в
|
|
||||||
релизе не найден подходящий файл обновления». Установщик берётся под ту
|
|
||||||
систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код
|
|
||||||
возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3».
|
|
||||||
|
|
||||||
Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг
|
|
||||||
на cp1252. Тот же класс дефекта, то же лекарство.
|
|
||||||
|
|
||||||
Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до
|
|
||||||
начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба:
|
|
||||||
`test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после
|
|
||||||
устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик
|
|
||||||
квот от другого теста успевает поднять `generation` между вызовами. Теперь
|
|
||||||
проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние
|
|
||||||
назад не откатывается), а не равенство. Пять полных прогонов со случайным
|
|
||||||
порядком — 777 passed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-2. Остальные P0 аудита — каждый подтверждён исполнением
|
|
||||||
|
|
||||||
### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось**
|
|
||||||
|
|
||||||
Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни
|
|
||||||
разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы
|
|
||||||
напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом
|
|
||||||
оказывался первый в списке — `checksums.txt`, а не пакет.
|
|
||||||
|
|
||||||
### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях
|
|
||||||
|
|
||||||
Измерено прогоном самой функции:
|
|
||||||
|
|
||||||
| Условие | Прежний вердикт |
|
|
||||||
|---|---|
|
|
||||||
| Полный обрыв сети | **PASS** |
|
|
||||||
| Манифест 404 (релиза нет) | **PASS** |
|
|
||||||
| Пакет 404 (ассет не загружен) | **PASS** |
|
|
||||||
|
|
||||||
Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза.
|
|
||||||
|
|
||||||
Разделено, как требовало задание: офлайновая часть (версии, тесты, updater,
|
|
||||||
статика, секреты, список разрешённых адресов) блокирует всегда; Publication
|
|
||||||
Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и
|
|
||||||
SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме
|
|
||||||
публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в
|
|
||||||
обычном прогоне CI, где релиза для ветки нет и быть не должно, результат
|
|
||||||
сообщается как есть и не блокирует. Неизмеренное называется причиной, а не
|
|
||||||
выдаётся за проверенное.
|
|
||||||
|
|
||||||
Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши
|
|
||||||
сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**.
|
|
||||||
|
|
||||||
### 3. localhost `/api/action` без CSRF — **подтвердилось**
|
|
||||||
|
|
||||||
CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не
|
|
||||||
**отправить** запрос. Измерено на конфигурации по умолчанию
|
|
||||||
(`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит
|
|
||||||
кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а
|
|
||||||
`request.json()` разбирает тело независимо от `Content-Type`. Запрос с
|
|
||||||
`Origin: https://evil.example.com` и без токена доходил до исполнителя
|
|
||||||
действий — отвечало уже само действие. Среди доступных действий
|
|
||||||
`clear_accounts`, `delete_credentials`, `set_main`.
|
|
||||||
|
|
||||||
Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса
|
|
||||||
запроса. Замер после правки:
|
|
||||||
|
|
||||||
| Запрос | Итог |
|
|
||||||
|---|---|
|
|
||||||
| чужой сайт, `Sec-Fetch-Site: cross-site` | **403** |
|
|
||||||
| чужой сайт, старый браузер (только `Origin`) | **403** |
|
|
||||||
| собственный интерфейс | 200 |
|
|
||||||
| адресная строка / расширение | 200 |
|
|
||||||
| не-браузерный клиент (curl, CLI) | 200 |
|
|
||||||
|
|
||||||
Защита распространена на все пять небезопасных методов, не только на
|
|
||||||
`/api/action`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1
|
|
||||||
|
|
||||||
1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом
|
|
||||||
по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с
|
|
||||||
записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы
|
|
||||||
каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а
|
|
||||||
запись-ссылка легла обычным файлом. CPython санирует пути сам.
|
|
||||||
|
|
||||||
Но это свойство реализации, а не обещание формата, и распаковка идёт в
|
|
||||||
корень установки. Граница сделана собственным инвариантом: каждая запись
|
|
||||||
проверяется до записи на диск, отклоняются абсолютные пути, выход через
|
|
||||||
`..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не
|
|
||||||
оставлен на усмотрение стандартной библиотеки.
|
|
||||||
|
|
||||||
2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump`
|
|
||||||
сериализовал текст обратно в строку, `isinstance(data, dict)` не
|
|
||||||
выполнялось никогда, таблица цен не загружалась ни разу, а `except` это
|
|
||||||
глушил.
|
|
||||||
|
|
||||||
3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие
|
|
||||||
Linux-джоба и позволяло четырём платформенным допущениям прятаться; на
|
|
||||||
первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не
|
|
||||||
показывал.
|
|
||||||
|
|
||||||
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
|
|
||||||
extra, secret-scan шире) — не трогал, по заданию это отдельные задания.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения задания — соблюдены
|
|
||||||
|
|
||||||
- Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил.
|
|
||||||
- Фронтенд не трогал: npm, сборки и фреймворков не добавлено.
|
|
||||||
- Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не
|
|
||||||
тронут.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не тронуты.
|
|
||||||
- Версия `0.1.3` не понижена.
|
|
||||||
- Неизмеренное названо причиной: Publication Gate вне режима публикации
|
|
||||||
печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту
|
|
||||||
|
|
||||||
Это самое важное из того, что не значилось ни в задании, ни в аудите.
|
|
||||||
|
|
||||||
**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних,
|
|
||||||
включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного
|
|
||||||
CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и
|
|
||||||
`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы
|
|
||||||
выкладывались мимо конвейера.
|
|
||||||
|
|
||||||
**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и
|
|
||||||
`update_manifest.json`, а `update_manager` ищет в релизе строго
|
|
||||||
`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат
|
|
||||||
`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть
|
|
||||||
собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего
|
|
||||||
не публиковал; **как только я их починил, случайная защита исчезла**: первый
|
|
||||||
же тег привёл бы к публикации «latest» без установщиков, и любая попытка
|
|
||||||
обновиться отвечала бы «В релизе не найден подходящий файл обновления для
|
|
||||||
текущей платформы».
|
|
||||||
|
|
||||||
Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by
|
|
||||||
the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор
|
|
||||||
содержит установщик и `checksums.txt`, и падает с названной причиной и
|
|
||||||
подсказкой про `installer/build_installer.ps1` и
|
|
||||||
`installer/build_installer_linux.sh`. После публикации добавлен шаг
|
|
||||||
`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий
|
|
||||||
режим, ради которого ворота и разделялись.
|
|
||||||
|
|
||||||
Чего я **не** делал: не переписывал сборку установщиков в `release.yml`.
|
|
||||||
Проверить это можно только выкладыванием настоящего релиза по тегу, а это
|
|
||||||
решение владельца, не исполнителя. Конвейер по-прежнему не доходит до
|
|
||||||
публикации — но теперь падает с честной причиной вместо чужой.
|
|
||||||
|
|
||||||
## Что стоит решить ревьюеру
|
|
||||||
|
|
||||||
- Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test
|
|
||||||
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
|
|
||||||
сломалось; если её будут включать — имена проверок брать новые.
|
|
||||||
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
|
|
||||||
каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже
|
|
||||||
встроен и блокирует (`--publication-only`, после публикации). Вручную:
|
|
||||||
`python scripts/release_gate.py --publication`.
|
|
||||||
- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает
|
|
||||||
zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и
|
|
||||||
`installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик
|
|
||||||
требует Linux-раннера, то есть релизной джобе нужна матрица. Работа
|
|
||||||
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
|
||||||
оставлена за владельцем.
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Передача Antigravity: A31 и A32
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Последовательно выполнить A31 и A32. Не объединять их в один огромный непроверяемый коммит.
|
|
||||||
|
|
||||||
Полные технические задания:
|
|
||||||
|
|
||||||
1. `agents/inbox/2026-08-25-A31-preflight-state-batching-pii.md`
|
|
||||||
2. `agents/inbox/2026-08-25-A32-remove-desktop.md`
|
|
||||||
|
|
||||||
## Текущее состояние на момент передачи
|
|
||||||
|
|
||||||
- `origin/main`: `c35bc48`
|
|
||||||
- A28: `origin/antigravity/subagents-role-registry` — `b149a6a`
|
|
||||||
- A29: `origin/antigravity/design-system-routing` — `a8c37ca`
|
|
||||||
- A30: `codex/workflow-canvas` — `32bf2c9`
|
|
||||||
- A28, A29 и A30 пока не являются предками `origin/main` и не являются предками друг друга.
|
|
||||||
- A31 и A32 ещё не реализованы.
|
|
||||||
|
|
||||||
Рабочая директория владельца содержит незакоммиченные сборочные артефакты. Их не забирать, не очищать и не перезаписывать. Работать в отдельном чистом worktree/клоне.
|
|
||||||
|
|
||||||
## Задача 1: подготовить интеграционную базу
|
|
||||||
|
|
||||||
До A31 и A32 собрать A28, A29 и A30 поверх актуального `origin/main` в отдельной интеграционной ветке. Конфликты разрешать по смыслу, сохраняя одновременно:
|
|
||||||
|
|
||||||
- реестр ролей и субагентов A28;
|
|
||||||
- дизайн-систему и routing drag-and-drop A29;
|
|
||||||
- workflow canvas, Agent Files и LIVE/EDIT A30;
|
|
||||||
- security fix `c35bc48`.
|
|
||||||
|
|
||||||
После интеграции выполнить `ruff check .`, полный `pytest tests/ -v` и `python scripts/release_gate.py`. Известный устаревший ассерт A30 на `overview-route-diagram` должен быть заменён проверкой актуального `workflow-canvas`, а не обходиться skip/xfailed.
|
|
||||||
|
|
||||||
Не начинать A31, пока интеграционная база не закоммичена, не отправлена в `origin` и release gate не зелёный. В отчёте дать SHA всех взятых голов и итоговый SHA интеграционной ветки.
|
|
||||||
|
|
||||||
## Задача 2: A31
|
|
||||||
|
|
||||||
Создать отдельную ветку от проверенной интеграционной базы и полностью выполнить `2026-08-25-A31-preflight-state-batching-pii.md`.
|
|
||||||
|
|
||||||
Обязателен порядок из задания: реализация Flash, затем независимый аудит Pro. Не заявлять выполнение проверок, которые фактически не запускались. Особенно приложить доказательства для намеренно сломанного preflight, восстановления workflow после перезапуска, отсутствия секретов в состоянии, failover занятого локального сервера, получения лимитов модели без выдуманных чисел и различимого маскирования почт.
|
|
||||||
|
|
||||||
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
|
|
||||||
|
|
||||||
## Задача 3: A32
|
|
||||||
|
|
||||||
Начинать только после принятия A31. Создать отдельную ветку от принятого результата A31 и полностью выполнить `2026-08-25-A32-remove-desktop.md`.
|
|
||||||
|
|
||||||
Первый шаг — таблица паритета десктопа и веба. Если обнаружена функция без веб-эквивалента, остановить удаление и честно перечислить пробелы. При полном паритете удалить десктоп, его launcher, зависимости и старый ярлык строго по заданию.
|
|
||||||
|
|
||||||
Обязательны реальные проверки чистой Windows-установки, обновления поверх старой версии с десктопом и Linux-установщика. Учётные данные, настройки и цепочки ролей при обновлении должны сохраниться. Реальный пропуск любой платформенной проверки отметить как пропуск, а не PASS.
|
|
||||||
|
|
||||||
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
|
|
||||||
|
|
||||||
## Запреты
|
|
||||||
|
|
||||||
- Не работать в грязной директории владельца.
|
|
||||||
- Не пушить реализацию напрямую в `main`.
|
|
||||||
- Не создавать тег `v0.1.1`.
|
|
||||||
- Не смешивать A31 и A32 в одной ветке или одном коммите.
|
|
||||||
- Не удалять десктоп до доказанного веб-паритета.
|
|
||||||
- Не подменять живые проверки моками и не выдумывать результаты платформенных прогонов.
|
|
||||||
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# Задание A42: подключение провайдеров — OpenRouter, NVIDIA, Ollama, квота Codex
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a42-provider-connect origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
|
||||||
|
|
||||||
Это задание **по коду**. Вёрстка и холст — отдельное задание A43, туда не залезать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец сообщает: «опенроутер не подключается, нвидиа не подключаются, кодекс выдаёт ошибку по квоте, хотя квоты полные, оллама не выдаёт облачные модели».
|
|
||||||
|
|
||||||
Причины найдены ревьюером и проверены по коду. Заново их выяснять не нужно — нужно чинить.
|
|
||||||
|
|
||||||
## Что проверено ревьюером
|
|
||||||
|
|
||||||
### OpenRouter и NVIDIA реализованы наполовину
|
|
||||||
|
|
||||||
```
|
|
||||||
adapters/__init__.py OpenRouterAdapter и NvidiaAdapter в реестре есть
|
|
||||||
web/static/index.html:163 пункты в списке провайдеров есть
|
|
||||||
app.js:2482 шаг мастера с полем API-ключа и Base URL есть
|
|
||||||
|
|
||||||
action_handler.py:574 add_account сохраняет ТОЛЬКО для
|
|
||||||
(local, local-llm, llama.cpp, ollama, vllm)
|
|
||||||
и только при непустом base_url
|
|
||||||
action_handler.py:588 для всех остальных возвращается ok=True с текстом
|
|
||||||
«Навигация» — и не сохраняется ничего
|
|
||||||
```
|
|
||||||
|
|
||||||
То есть мастер докладывает об успехе и **не сохраняет ничего**. Аккаунт не появляется, потому что его никто не создал.
|
|
||||||
|
|
||||||
Дальше по цепочке пусто тоже:
|
|
||||||
|
|
||||||
```
|
|
||||||
auto_assigner.py:129 слоты объявлены для ollama; openrouter и nvidia отсутствуют
|
|
||||||
auto_assigner.py:219 роли по умолчанию — то же самое
|
|
||||||
router_config.py в конфигурации по умолчанию нет ни одного из трёх
|
|
||||||
model_discovery_service.py:216 _probe_provider не имеет ветки ни для
|
|
||||||
openrouter, ни для nvidia, и возвращает None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama обнаруживает не то
|
|
||||||
|
|
||||||
`model_discovery_service.py:325` заводит `ollama` в одну ветку с `local`, `llama.cpp`, `vllm`. Эта ветка:
|
|
||||||
|
|
||||||
1. перебирает **зашитые** идентификаторы `local-1` и `local-2` — профиль `ollama-1` не смотрит вообще;
|
|
||||||
2. читает учётные данные провайдера `local`, а не `ollama`;
|
|
||||||
3. по умолчанию идёт на `http://127.0.0.1:8081/v1` — это порт llama.cpp, а не Ollama (11434);
|
|
||||||
4. дёргает `/v1/models` и ничего не знает про облачные модели Ollama.
|
|
||||||
|
|
||||||
Скриншот владельца: «Список моделей ещё не получен от провайдера ollama» при подключённом `ollama-1`.
|
|
||||||
|
|
||||||
Та же болезнь рядом: ветка Codex перебирает зашитые `codex-orch`, `codex-worker-1`, `codex-worker-2`. После A26 идентификаторы выдаются автоматически (`codex-4`, `codex-5`), и такой профиль обнаружение пропустит.
|
|
||||||
|
|
||||||
### «Квота исчерпана» при полной квоте
|
|
||||||
|
|
||||||
На скриншоте у Codex значок «Квота исчерпана», а Session и Weekly показывают `Н/Д`. То есть **вердикт об исчерпании выносится там, где квота не измерена вовсе**.
|
|
||||||
|
|
||||||
```
|
|
||||||
unified_health.py:467 ветка: max_cd > 0 либо overall_state == QUOTA_EXHAUSTED
|
|
||||||
→ health_state = STATUS_QUOTA_EXHAUSTED
|
|
||||||
unified_health.py:470 ветка RATE_LIMITED идёт НИЖЕ
|
|
||||||
```
|
|
||||||
|
|
||||||
`max_cd` берётся из `frec.reset_at > now` — это **окно отката после ошибки**, а не остаток квоты. Отсюда два разных дефекта:
|
|
||||||
|
|
||||||
1. Любой откат показывается как исчерпание квоты, хотя квота может быть полной.
|
|
||||||
2. Ветка `RATE_LIMITED` практически мертва: при активном лимите запросов `reset_at` всегда в будущем, поэтому строка 467 срабатывает раньше и лимит запросов выдаёт себя за исчерпанную квоту.
|
|
||||||
|
|
||||||
И третье, в `health_tracker.py:483`: при пустом имени модели или значении `default` исчерпанным помечается **весь аккаунт** (`record.overall_state`). Hermes имя модели передаёт не всегда.
|
|
||||||
|
|
||||||
Классификатор в `codex_adapter.py:132` ловит подстроку `quota` в любом месте текста ошибки — проверить, не попадают ли туда сообщения, к квоте не относящиеся.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. OpenRouter и NVIDIA подключаются по-настоящему
|
|
||||||
|
|
||||||
1. **`add_account` сохраняет профиль** для `openrouter`, `nvidia`, `nvidia-nim`: создаёт определение профиля, пишет учётные данные (ключ и адрес), назначает роль — по образцу существующей локальной ветки.
|
|
||||||
2. **Слоты и роли по умолчанию** для обоих провайдеров в `AutoAssigner`, как сделано для `ollama`.
|
|
||||||
3. **Адреса по умолчанию**: `https://openrouter.ai/api/v1` и `https://integrate.api.nvidia.com/v1`; владелец может переопределить в мастере.
|
|
||||||
4. **Ветка с мнимым успехом не должна остаться ловушкой.** Провайдер, для которого сохранение не реализовано, обязан получать честный отказ с причиной, а не `ok: True`. Это главное требование пункта: молчаливый успех стоил владельцу нескольких попыток подключения.
|
|
||||||
|
|
||||||
## P0-2. Обнаружение моделей для трёх провайдеров
|
|
||||||
|
|
||||||
1. **OpenRouter**: запрос списка моделей по адресу профиля с его ключом.
|
|
||||||
2. **NVIDIA**: то же самое.
|
|
||||||
3. **Ollama — отдельная ветка**, не общая с llama.cpp:
|
|
||||||
- адрес берётся из **самого профиля**, а не из зашитых `local-1`/`local-2`;
|
|
||||||
- учётные данные читаются для провайдера `ollama`;
|
|
||||||
- по умолчанию `http://127.0.0.1:11434`;
|
|
||||||
- локальные модели — через нативный `/api/tags`;
|
|
||||||
- **облачные модели Ollama** — отдельный источник, требующий ключа. Выяснить по действующей документации Ollama способ и адрес; **не выдумывать эндпоинт**. Если способ не подтверждён — так и написать в отчёте, а в интерфейсе показать `Н/Д` с причиной.
|
|
||||||
4. **Зашитые идентификаторы профилей убрать везде**, включая ветку Codex: перебирать профили провайдера из конфигурации. После A26 идентификаторы выдаются автоматически, и любой зашитый список рано или поздно промахнётся.
|
|
||||||
5. **Ошибка обнаружения показывается с текстом ответа сервера.** Сейчас `_probe_provider` возвращает `None` и когда ветки нет, и когда сервер отказал — владелец не может отличить одно от другого.
|
|
||||||
|
|
||||||
## P0-3. Квота говорит только то, что измерено
|
|
||||||
|
|
||||||
1. **Откат после ошибки — это не исчерпание квоты.** Разделить состояния: исчерпание объявлять по измеренному остатку, откат показывать как откат с причиной и временем окончания.
|
|
||||||
2. **Порядок веток исправить**: лимит запросов не должен выдавать себя за исчерпанную квоту.
|
|
||||||
3. **Ошибка без имени модели не помечает весь аккаунт.** Помечать конкретное семейство; общий вердикт — только при подтверждении.
|
|
||||||
4. **Ярлык называет источник.** «Квота исчерпана» — когда есть измерение. Иначе «Откат до HH:MM после ошибки: текст».
|
|
||||||
5. **Классификатор Codex** проверить на ложные срабатывания подстроки `quota`.
|
|
||||||
|
|
||||||
## P0-4. Проверка исполнением
|
|
||||||
|
|
||||||
Заглушек недостаточно, но и ключей владельца у исполнителя нет. Поэтому:
|
|
||||||
|
|
||||||
1. **Сохранение профиля** проверить с заведомо неверным ключом: профиль обязан создаться, а проверка подключения — вернуть внятную ошибку авторизации, а не тишину.
|
|
||||||
2. **Ollama** проверить на живом сервере: локальные модели через `/api/tags` обязаны появиться в списке.
|
|
||||||
3. **Квота**: смоделировать откат после ошибки и убедиться, что интерфейс не пишет «квота исчерпана» при неизмеренной квоте.
|
|
||||||
4. **Отказ вместо мнимого успеха** проверить отдельно.
|
|
||||||
|
|
||||||
## P0-5. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Искать оставшиеся зашитые идентификаторы профилей** по всему коду — это повторяющийся класс дефекта.
|
|
||||||
2. **Проверить, что мнимых успехов не осталось**: действие, ничего не сохранившее, не возвращает `ok: True`.
|
|
||||||
3. **Эндпоинт облачных моделей Ollama** сверить с документацией. Выдуманный адрес — дефект того же рода, что выдуманные метрики в A38.
|
|
||||||
4. **Побочные изменения** объяснить.
|
|
||||||
5. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Ключи владельца не запрашивать и в репозиторий не класть.
|
|
||||||
- Каталог `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Вёрстку и холст не менять — это A43.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений: неизмеренное показывать как `Н/Д` с причиной.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. OpenRouter и NVIDIA подключаются: профиль создаётся, учётные данные сохраняются, аккаунт виден в списке; проверено.
|
|
||||||
3. Действие, ничего не сохранившее, возвращает отказ с причиной; проверено.
|
|
||||||
4. Обнаружение моделей работает для openrouter, nvidia и ollama; для Ollama проверено на живом сервере.
|
|
||||||
5. Зашитых идентификаторов профилей в обнаружении не осталось.
|
|
||||||
6. Ошибка обнаружения доходит до интерфейса с текстом.
|
|
||||||
7. Откат после ошибки не показывается как исчерпание квоты; лимит запросов показывается как лимит запросов.
|
|
||||||
8. Ошибка без имени модели не помечает весь аккаунт.
|
|
||||||
9. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Два провайдера нельзя подключить вовсе, и мастер при этом рапортует об успехе — владелец несколько раз повторял заведомо безрезультатное действие. Третий подключается, но опрашивается по чужому адресу и чужому имени профиля. А Codex объявляется исчерпанным по квоте в тот момент, когда квота не измерена ни разу. Общее у всех четырёх — интерфейс утверждает то, чего не проверял.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
# Задание A43: интерфейс по макетам и работающий холст
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a43-frontend-canvas origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
|
||||||
|
|
||||||
Это задание **по интерфейсу**. Провайдеры, обнаружение моделей и квоты — задание A42, туда не залезать. Пересечение файлов: `app.js` и `workflow.js` правит только это задание; A42 работает в Python.
|
|
||||||
|
|
||||||
Исполнитель работает на машине владельца (Windows), где хаб запущен и есть живой снапшот с подключёнными аккаунтами. Это принципиально: макет надо сверять с работающим интерфейсом, а не с воображаемым.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец: «криво отрисовано», «окно интерактивно ужасно, посмотри как сделано у n8n», «вообще весь интерфейс не соответствует фронтенду, просто посмотри как отрисовано в макете».
|
|
||||||
|
|
||||||
## Что уже сделано ревьюером — не переделывать
|
|
||||||
|
|
||||||
В базовой ветке уже исправлено, проверено и закоммичено:
|
|
||||||
|
|
||||||
```
|
|
||||||
app.js экран маршрутизации читал currentSnapshot.profiles — такого ключа
|
|
||||||
в снапшоте нет (поле называется all_profiles). Отсюда пустая колонка
|
|
||||||
аккаунтов, счётчик «0 аккаунтов» и иконки-заглушки в цепочках.
|
|
||||||
app.js убрана полоса квоты с зашитым width:80%, одинаковая у всех аккаунтов
|
|
||||||
workflow.css подписи связей центрируются (не было text-anchor) и получили обводку
|
|
||||||
workflow.js список моделей берётся из discovered_models провайдера, а не из
|
|
||||||
preferred_models профиля; настроенная модель всегда есть в списке
|
|
||||||
```
|
|
||||||
|
|
||||||
Последнее чинило скрытую подмену: если модели агента не было в списке, ни один вариант не выбирался, показывался первый, и сохранение записывало агенту не ту модель.
|
|
||||||
|
|
||||||
## Про генераторы интерфейса
|
|
||||||
|
|
||||||
Владелец спрашивал про `github.com/abi/screenshot-to-code`. Ревьюер проверил и **не рекомендует**: инструмент выдаёт самостоятельную страницу на Tailwind без данных, а клиент здесь без сборки и без npm, всё держится на привязке к `/api/snapshot` (решение зафиксировано в `docs/web-api/CONTRACT.md` §1). Переподключать сгенерированную страницу к снапшоту, действиям и опросу состояния дороже, чем сверстать по макету.
|
|
||||||
|
|
||||||
Опираться на макеты из `Desktop/фронтенд/` напрямую.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Холст ведёт себя как холст
|
|
||||||
|
|
||||||
Сейчас `workflow.css:28` задаёт `.workflow-canvas` фиксированную высоту 430 px и `overflow:hidden`, а обработчики мыши висят только на узлах и портах (`workflow.js:171`). Колесо не обрабатывается, полотно не двигается. Всё, что выехало за 430 px, недостижимо — узел не вернуть, связь не увидеть.
|
|
||||||
|
|
||||||
Требуется поведение, привычное по n8n:
|
|
||||||
|
|
||||||
1. **Панорамирование полотна** — перетаскиванием пустого места и средней кнопкой.
|
|
||||||
2. **Масштаб колесом** с курсором как центром, а не только кнопками.
|
|
||||||
3. **Холст тянется по высоте окна**, а не заперт в 430 px.
|
|
||||||
4. **Вписать в экран** — кнопка уже есть (`fitWorkflowGraph`), она должна учитывать панорамирование.
|
|
||||||
5. **Узел нельзя утащить в недосягаемость**: либо границы, либо «вписать» всегда возвращает всё в поле зрения.
|
|
||||||
|
|
||||||
Связи и узлы считаются в одной системе координат — это ревьюер проверил, ошибки там нет. При добавлении панорамирования **сохранить это свойство**: смещение обязано применяться к обоим слоям одинаково, иначе связи отклеятся от узлов.
|
|
||||||
|
|
||||||
## P0-2. Экраны соответствуют макетам
|
|
||||||
|
|
||||||
Пройти по макетам из `Desktop/фронтенд/` и привести экраны в соответствие: сетка, отступы, типографика, состояния карточек, расположение панелей.
|
|
||||||
|
|
||||||
1. **Расхождения перечислить списком** до начала работы — что именно не совпадает на каждом экране. Список приложить к отчёту.
|
|
||||||
2. **Скриншот до и после** по каждому экрану. Это единственный способ показать владельцу результат: он сравнивает глазами.
|
|
||||||
3. **Три темы остаются рабочими** — светлая, средняя, тёмная. Средняя была реализована в стилях, но отсутствовала в списке выбора; проверить, что все три переключаются.
|
|
||||||
4. **Ничего не ломать в данных.** Экран берёт данные из снапшота; если макет требует поля, которого в снапшоте нет, — показать `Н/Д` с причиной и назвать это в отчёте, а не придумать значение.
|
|
||||||
|
|
||||||
## P0-3. Пустые состояния и честность
|
|
||||||
|
|
||||||
1. **Пустое — это пустое, а не ошибка.** Нет подключённых аккаунтов — экран говорит об этом и предлагает подключить, а не показывает ноль как поломку.
|
|
||||||
2. **Загрузка отличается от отсутствия.** «Список моделей ещё не получен» и «моделей нет» — разные сообщения.
|
|
||||||
3. **Ни одного зашитого числа в интерфейсе.** Полоса с `width:80%` уже убрана; поискать оставшиеся такие же. Любой процент, столбик или счётчик обязан приходить из снапшота.
|
|
||||||
|
|
||||||
## P0-4. Проверка исполнением
|
|
||||||
|
|
||||||
Прогнать тесты недостаточно — дефекты этого задания видны только глазами.
|
|
||||||
|
|
||||||
1. **Открыть хаб** и пройти все экраны на живом снапшоте владельца.
|
|
||||||
2. **Холст**: подвигать полотно, покрутить колесо, утащить узел за край и вернуть кнопкой «вписать».
|
|
||||||
3. **Инспектор агента**: убедиться, что показанная модель совпадает с настроенной, а список содержит модели провайдера.
|
|
||||||
4. **Маршрутизация**: колонка аккаунтов заполнена, счётчик совпадает с числом подключённых, перетаскивание работает.
|
|
||||||
5. **Три темы** переключить и посмотреть каждый экран.
|
|
||||||
|
|
||||||
## P0-5. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Сверить скриншоты с макетами**, а не с описанием работы. Совпадение проверяется глазами, а не отчётом исполнителя.
|
|
||||||
2. **Связи не отклеились от узлов** ни на одном масштабе и смещении — проверить на нескольких значениях.
|
|
||||||
3. **Зашитые числа** искать целенаправленно по всему клиенту.
|
|
||||||
4. **Проверить, что данные не потерялись**: экраны, которые работали, продолжают работать.
|
|
||||||
5. **Побочные изменения** объяснить.
|
|
||||||
6. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1. Tailwind, React и генераторы страниц не вносить.
|
|
||||||
- Python не трогать: провайдеры и квоты — задание A42.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Холст панорамируется и масштабируется колесом; высота не заперта; связи держатся за узлы на любом масштабе и смещении — проверено.
|
|
||||||
3. Расхождения с макетами перечислены списком; по каждому экрану приложены скриншоты до и после.
|
|
||||||
4. Три темы работают на всех экранах.
|
|
||||||
5. Пустые состояния показываются как пустые, загрузка отличается от отсутствия.
|
|
||||||
6. Зашитых чисел в интерфейсе не осталось.
|
|
||||||
7. Экран маршрутизации, инспектор агента и список аккаунтов проверены на живом снапшоте.
|
|
||||||
8. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Владелец смотрит на готовый макет и на работающую программу и видит разные вещи. Плюс холст, из которого узел можно утащить за край и не вернуть. Задание закрывает ровно это: чтобы экран совпадал с макетом, а граф вёл себя как граф, к которому владелец привык в n8n.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# Задание A44: вернуть кодер в строй, поставить llama-swap, поправить отчёт A40
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
Ветка A40 (`origin/antigravity/a40-benchmark-redo`, `d545252`) — правки отчёта ложатся туда же, где он живёт.
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a44-restore-server origin/antigravity/a40-benchmark-redo
|
|
||||||
git merge origin/main # ветка A40 отстала: в main уже A41 и правки ревьюера
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
|
||||||
|
|
||||||
Задание **по коду и серверу**. Вёрстка — A43, провайдеры — A42, туда не залезать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что признано и переделке не подлежит
|
|
||||||
|
|
||||||
Ревьюер проверил A40 исполнением на сервере. Проверка таблицы пройдена:
|
|
||||||
|
|
||||||
```
|
|
||||||
все семь файлов GGUF существуют по указанным путям
|
|
||||||
размеры совпадают с отчётом ДО БАЙТА (stat -c %s по каждому)
|
|
||||||
sha256 первых 64 МБ granite-4.2 пересчитана независимо — совпала:
|
|
||||||
f155ab58fe3ff46c4daa7d65633347da343143771238ddf63ecba25b8e10a06d
|
|
||||||
DeepSeek-Coder-V2-Lite и Phi-4, выдуманные в A38, действительно скачаны
|
|
||||||
и измерены; прежние числа отозваны
|
|
||||||
```
|
|
||||||
|
|
||||||
Это хорошая работа, и требование P0-1 из A40 выполнено. Стенд, набор задач и таблицу **не переделывать**.
|
|
||||||
|
|
||||||
Претензии ниже касаются состояния сервера и двух столбцов отчёта.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что сломано — проверено ревьюером
|
|
||||||
|
|
||||||
### Кодер владельца не запущен как служба
|
|
||||||
|
|
||||||
```
|
|
||||||
systemctl is-active qwen-coder → inactive
|
|
||||||
systemctl show qwen-coder SubState → dead
|
|
||||||
|
|
||||||
порт 8081 при этом отвечает: его обслуживает процесс, поднятый ВРУЧНУЮ
|
|
||||||
PID 2713480, ELAPSED 07:50 на момент проверки
|
|
||||||
/home/ochenstarik/llama.cpp/build/bin/llama-server -m .../Qwen3.8-27B-Q4_K_M.gguf
|
|
||||||
```
|
|
||||||
|
|
||||||
Перезагрузка сервера или падение процесса — и локального кодера нет. Владелец пользуется этой машиной ежедневно.
|
|
||||||
|
|
||||||
### Контекст урезан вшестеро против штатного
|
|
||||||
|
|
||||||
```
|
|
||||||
/etc/systemd/system/qwen-coder.service ExecStart ... -c 196608
|
|
||||||
фактически запущено -c 32768
|
|
||||||
```
|
|
||||||
|
|
||||||
У Hermes порог **64К контекста**: при 32768 модель не проходит отбор, и локальный кодер бесполезен. Это ровно та проблема, ради которой делалось A39.
|
|
||||||
|
|
||||||
### Отсюда же расхождение скоростей в отчёте
|
|
||||||
|
|
||||||
Отчёт даёт Qwen3.8-27B **30,31 ток/с**. Независимый замер ревьюера на штатной конфигурации давал **13,6 ток/с**. Разницу объясняет контекст: замеры шли на 32К, служба владельца работает на 192К.
|
|
||||||
|
|
||||||
В строке «Условия измерений» перечислены квантование, `-ngl 99`, `--flash-attn on`, `--cache-type-k/v q8_0`, `--parallel 1`, `--temp 0.2` — и **размер контекста не указан вовсе**. Без него числа нельзя соотнести с реальной установкой владельца, а именно ради этого отчёт и делался.
|
|
||||||
|
|
||||||
### Столбец VRAM измеряет не то
|
|
||||||
|
|
||||||
```
|
|
||||||
отчёт: Qwen3 4B Instruct 2507 → 24 894 МиБ
|
|
||||||
живой замер того же процесса:
|
|
||||||
nvidia-smi --query-compute-apps=pid,used_memory
|
|
||||||
1570163 5 440 МиБ llama-server ... Qwen3-4B ...
|
|
||||||
```
|
|
||||||
|
|
||||||
В отчёт попала **общая занятость карты** вместе с соседней резидентной моделью, а не потребление самого процесса. Отсюда абсурд: 4B «занимает» 24 894 МиБ, а 27B — 24 696 МиБ. Для планирования «сколько моделей поместится» столбец непригоден, а владелец задаёт именно этот вопрос.
|
|
||||||
|
|
||||||
### llama-swap не установлен
|
|
||||||
|
|
||||||
```
|
|
||||||
command -v llama-swap → не найден
|
|
||||||
systemctl is-active llama-swap → inactive
|
|
||||||
```
|
|
||||||
|
|
||||||
Установка llama-swap была критерием приёмки 2 в A38 и не выполнена ни там, ни здесь.
|
|
||||||
|
|
||||||
### Мусор от прерванной закачки
|
|
||||||
|
|
||||||
```
|
|
||||||
/srv/ai/models/nemotron-3.5-30b 511 МБ
|
|
||||||
```
|
|
||||||
|
|
||||||
Отчёт честно говорит, что модель не проверена. Но огрызок остался лежать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Кодер возвращается в штатное состояние
|
|
||||||
|
|
||||||
Это первое по важности: сейчас у владельца сломан рабочий инструмент.
|
|
||||||
|
|
||||||
1. Ручной процесс на 8081 остановить.
|
|
||||||
2. `qwen-coder` поднять **штатно, через systemd**, с контекстом `196608` из юнита.
|
|
||||||
3. Убедиться, что после `systemctl restart` служба поднимается сама и порт отвечает.
|
|
||||||
4. **Проверить включение в автозапуск** (`systemctl is-enabled`): служба обязана пережить перезагрузку.
|
|
||||||
5. `qwen-compressor` на 8082 проверить тем же порядком.
|
|
||||||
|
|
||||||
Юнит-файлы **не переписывать** без нужды: это машина владельца, он правит их сам. Если правка всё же необходима — обосновать в отчёте отдельным пунктом.
|
|
||||||
|
|
||||||
## P0-2. llama-swap
|
|
||||||
|
|
||||||
1. Поставить `mostlygeek/llama-swap` (Go, MIT) **рядом** с работающими службами, не ломая их.
|
|
||||||
2. В конфигурацию внести все модели, лежащие в `/srv/ai/models/`, каждую отдельной записью со своими параметрами запуска.
|
|
||||||
3. Таймаут выгрузки настраивается.
|
|
||||||
4. **Проверить замером `nvidia-smi`, что выгрузка действительно освобождает видеопамять** — по документации не принимать.
|
|
||||||
5. **Откат одной командой** описать и проверить: если llama-swap мешает, `qwen-coder` и `qwen-compressor` возвращаются в прежний вид.
|
|
||||||
6. Штатные порты 8081 и 8082 остаются за службами владельца. llama-swap слушает свой порт и в работу Hermes не вмешивается, пока владелец не переключит.
|
|
||||||
|
|
||||||
Обоснование, почему это стоит делать: суммарно четыре интересующие владельца модели занимают 25,5 ГиБ, а на сервере **45 ГБ уже занято страничным кэшем при 62 ГБ всего**. Модели помещаются в оперативную память целиком, поэтому переключение между ними — копирование по PCIe, а не чтение с диска на 187 МБ/с. Это снимает главное возражение против свопа.
|
|
||||||
|
|
||||||
## P0-3. Две правки отчёта
|
|
||||||
|
|
||||||
Таблицу не трогать, кроме следующего.
|
|
||||||
|
|
||||||
1. **Размер контекста внести в условия измерений.** Если замеры шли на 32768 — так и написать. Числа, снятые на 32К, не выдавать за характеристику установки владельца, работающей на 192К.
|
|
||||||
2. **Столбец VRAM пересчитать на потребление процесса**, а не карты: `nvidia-smi --query-compute-apps=pid,used_memory`. Если пересчёт требует повторных запусков — либо перезамерить, либо честно пометить столбец как неизмеренный и убрать числа. Оставлять заведомо неверные значения нельзя.
|
|
||||||
3. **Добавить строку про порог Hermes**: какие из моделей держат 64К контекста и с какой скоростью. Это тот вопрос, ради которого владелец сравнение и заказывал.
|
|
||||||
|
|
||||||
## P0-4. Ответ на вопрос владельца
|
|
||||||
|
|
||||||
Владелец спрашивает, можно ли держать несколько лёгких моделей сразу: Qwen2.5-Coder-14B, Qwen3-4B-Instruct-2507, DeepSeek-Coder-V2-Lite, Granite-4.2-8B.
|
|
||||||
|
|
||||||
Арифметика ревьюера по проверенным размерам файлов:
|
|
||||||
|
|
||||||
```
|
|
||||||
Qwen2.5-Coder-14B 8 571 МиБ
|
|
||||||
DeepSeek-V2-Lite 9 884 МиБ
|
|
||||||
Granite-4.2-8B 5 283 МиБ
|
|
||||||
Qwen3-4B-2507 2 382 МиБ
|
|
||||||
──────────
|
|
||||||
только веса 26 120 МиБ из 32 768
|
|
||||||
остаётся 6 648 МиБ на четыре контекста и буферы
|
|
||||||
```
|
|
||||||
|
|
||||||
Замеренная надбавка у живых процессов на 32К — от 1 154 МиБ до 3 058 МиБ на экземпляр.
|
|
||||||
|
|
||||||
Требуется **проверить это замером**, а не расчётом: поднять три модели без DeepSeek одновременно и снять `nvidia-smi` по процессам; затем попробовать четыре. Дать владельцу таблицу «сколько моделей и с каким контекстом помещается» с настоящими числами.
|
|
||||||
|
|
||||||
## P0-5. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Перезагрузить сервер** (согласовав окно с владельцем) и убедиться, что кодер и компрессор поднялись сами. Это единственная настоящая проверка пункта P0-1.
|
|
||||||
2. **Убедиться, что контекст 196608**, а не 32768: запросить у сервера и сверить.
|
|
||||||
3. **Проверить, что llama-swap не мешает** штатным службам: обе работают, порты отвечают.
|
|
||||||
4. **Сверить пересчитанный столбец VRAM** с `--query-compute-apps` независимо.
|
|
||||||
5. **Убедиться, что в отчёте не осталось чисел без указания условий**, при которых они сняты.
|
|
||||||
6. **Побочные изменения** объяснить.
|
|
||||||
7. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Сервер рабочий. Окно для перезагрузки согласовать с владельцем.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Конфигурацию хаба не менять.
|
|
||||||
- Юнит-файлы владельца без обоснования не переписывать.
|
|
||||||
- Место на диске контролировать: свободно 257 ГБ.
|
|
||||||
- Версию `0.1.1` не поднимать, тег не создавать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. `qwen-coder` работает через systemd с контекстом 196608, включён в автозапуск, пережил перезагрузку — вывод приложен.
|
|
||||||
3. `qwen-compressor` проверен тем же порядком.
|
|
||||||
4. llama-swap установлен, содержит все модели из `/srv/ai/models/`, выгрузка освобождает видеопамять — подтверждено `nvidia-smi`; откат описан и проверен.
|
|
||||||
5. Штатные службы llama-swap не сломал.
|
|
||||||
6. В условиях измерений отчёта указан размер контекста.
|
|
||||||
7. Столбец VRAM показывает потребление процесса либо честно помечен неизмеренным.
|
|
||||||
8. В отчёте есть ответ, какие модели держат 64К и с какой скоростью.
|
|
||||||
9. Замерено и приложено, сколько моделей помещается одновременно и с каким контекстом.
|
|
||||||
10. Огрызок `nemotron-3.5-30b` убран либо докачан; выбор объяснён.
|
|
||||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. После слияния с `origin/main` ожидается **491 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Замеры в A40 сделаны честно, и это заметный шаг после A38. Но ради них у владельца остановили кодер, запустили его вручную с контекстом вшестеро меньше штатного и в таком виде оставили — а при 32К модель не проходит порог Hermes и в работе бесполезна. Сначала вернуть инструмент в строй, потом договорить в отчёте то, что осталось недосказанным: при каком контексте сняты числа и сколько памяти занимает каждая модель на самом деле.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
# Задание A45: три новых кандидата в локальные кодеры
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`ff303b5`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a45-moe-candidates origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** исполняет замеры, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
|
||||||
|
|
||||||
**Выполнять после A44.** Причина не в приоритетах, а в железе: видеокарта одна, и оба задания её занимают. A44 первым делом останавливает ручной процесс на 8081 и возвращает кодер под systemd — начинать замеры до этого значит мешать друг другу и получить искажённые тайминги.
|
|
||||||
|
|
||||||
**Файлы A44 не трогать.** `benchmarks/BENCHMARK_REPORT.md` и `benchmarks/benchmark_results.json` правит A44; здесь пишется отдельный отчёт (см. P0-4). Стенд `benchmarks/benchmark_suite.py` используется как есть, без правок.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец нашёл на huggingface.co новые модели и спрашивает, есть ли что-то интересное. Ревьюер отобрал три кандидата и проверил их пригодность к этому железу. Нужно измерить.
|
|
||||||
|
|
||||||
## Что проверено ревьюером — заново не выяснять
|
|
||||||
|
|
||||||
Размеры получены через API репозиториев HuggingFace, а не из карточек моделей.
|
|
||||||
|
|
||||||
| Порядок | Репозиторий | Файл | Размер |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF` | `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf` | 17,28 ГиБ |
|
|
||||||
| 2 | `bartowski/Qwen2.5-Coder-32B-Instruct-GGUF` | `Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf` | 18,49 ГиБ |
|
|
||||||
| 3 | `peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF` | `Tiel-Coder-35B-A3B-UD-Q4_K_S.gguf` | 19,46 ГиБ |
|
|
||||||
|
|
||||||
Пересобирать llama.cpp не нужно:
|
|
||||||
|
|
||||||
```
|
|
||||||
сборка на сервере: build 10597, commit 95b8e33e1
|
|
||||||
libllama.so содержит: qwen3moe, qwen35moe, deepseek2, granite
|
|
||||||
```
|
|
||||||
|
|
||||||
Место: свободно 256 ГБ, три модели занимают 55 ГиБ.
|
|
||||||
|
|
||||||
## Почему именно эти три и в этом порядке
|
|
||||||
|
|
||||||
**Qwen3-Coder-30B-A3B — главная.** MoE: 30 миллиардов всего, **3 миллиарда активных**. На V100 производительность упирается в пропускную способность памяти, поэтому скорость определяется активными параметрами, а память — общими. Ожидается скорость малой модели при качестве тридцатимиллиардного кодера. Это ровно та гипотеза, ради которой A38 брала Nemotron и до замера не довела. 12,8 млн скачиваний, 933 отметки — сборка обкатанная.
|
|
||||||
|
|
||||||
По размеру садится на место нынешнего кодера: 17,28 ГиБ против 17,67 у Qwen3.8-27B, то есть под контекст остаётся столько же.
|
|
||||||
|
|
||||||
**Qwen2.5-Coder-32B — про потолок качества.** Плотная, старшая в семействе нынешнего лидера. Числилась кандидатом ещё в A40 и до замера не дошла. Скорости от неё не ждут: плотные 32B на этом железе должны идти примерно вдвое медленнее 14B. Вопрос к ней один — покупается ли за потерю скорости реальный прирост качества. 14B даёт 75% на стенде.
|
|
||||||
|
|
||||||
**Tiel-Coder-35B-A3B — третья, и только после двух первых.** Тоже MoE с тремя активными, первое место в трендах среди кодеров. Но выложена 31 августа, автор незнакомый, 145 отметок против 933 у Qwen. Не обкатана.
|
|
||||||
|
|
||||||
## Ожидания ревьюера — это не измерения
|
|
||||||
|
|
||||||
Всё, что выше сказано про ожидаемую скорость, выведено из замеренных свойств железа и **числами в отчёт не переносится**. В таблице стоят только измеренные значения.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Правило из A40 действует без изменений
|
|
||||||
|
|
||||||
**Строка в отчёте появляется только после того, как модель отработала на стенде.**
|
|
||||||
|
|
||||||
Для каждой строки обязательны:
|
|
||||||
|
|
||||||
1. **Абсолютный путь к файлу GGUF** на сервере.
|
|
||||||
2. **Размер файла в байтах** из `stat`, а не из карточки модели.
|
|
||||||
3. **Контрольная сумма** первых мегабайт или `sha256`.
|
|
||||||
4. **Сырые тайминги** из поля `timings` ответа `llama-server`, не пересчитанные вручную.
|
|
||||||
5. **Имя сборки** из `general.name` метаданных GGUF, а не из названия каталога.
|
|
||||||
|
|
||||||
Модель не скачалась, не запустилась или не влезла — **строки в таблице нет**, вместо неё раздел «не проверено» с причиной. Это полноценный результат, он принимается; выдуманные числа — нет.
|
|
||||||
|
|
||||||
## P0-2. Замер на 64К обязателен
|
|
||||||
|
|
||||||
Это главное отличие от A40 и главная причина, по которой задание вообще нужно.
|
|
||||||
|
|
||||||
1. **Мерить на 64К контекста**, а не только на 32К. Это порог отбора у Hermes: модель, не держащая 64К, в работу не идёт, и замер на 32К на вопрос владельца не отвечает.
|
|
||||||
2. Если модель на 64К не помещается или деградирует — **так и записать**, с числами.
|
|
||||||
3. **Длинный контекст у MoE под особым подозрением.** DeepSeek-Coder-V2-Lite, тоже MoE с малым числом активных, по замеру A40 на 32К проваливается до 3,35 ток/с и уходит в таймаут. Проверить целенаправленно, не повторяется ли это у Qwen3-Coder и Tiel: если повторяется, вся привлекательность MoE на этом железе иллюзорна, и это важнейший вывод задания.
|
|
||||||
4. Дополнительно снять 32К — для сопоставимости с таблицей A40.
|
|
||||||
|
|
||||||
## P0-3. Одинаковые условия
|
|
||||||
|
|
||||||
1. **Режим мышления выключен у всех**, как в A40.
|
|
||||||
2. Квантование Q4_K_M, где доступно; у Tiel его нет — взят `UD-Q4_K_S`, и это **оговорить в отчёте** отдельной строкой.
|
|
||||||
3. `--parallel 1`, посторонних запросов во время замера нет.
|
|
||||||
4. **Размер контекста указывать при каждом числе.** В A40 его забыли указать вовсе, и числа оказалось не с чем соотнести.
|
|
||||||
5. **VRAM мерить по процессу**: `nvidia-smi --query-compute-apps=pid,used_memory`, а не общую занятость карты. В A40 столбец собрал занятость вместе с соседней резидентной моделью и стал бесполезен.
|
|
||||||
|
|
||||||
## P0-4. Что измерять и куда писать
|
|
||||||
|
|
||||||
Как в A40: генерация и обработка промпта в токенах в секунду, видеопамять по процессу, время холодной загрузки, прохождение 12 задач стенда, поведение на длинном контексте.
|
|
||||||
|
|
||||||
Отчёт — **новый файл** `benchmarks/BENCHMARK_MOE_CANDIDATES.md` и отдельный файл результатов. `BENCHMARK_REPORT.md` и `benchmark_results.json` не трогать: их правит A44, и одновременная запись даст конфликт.
|
|
||||||
|
|
||||||
Вывод в одну строку на каждую модель: годится ли она заменой нынешнему кодеру и почему. **Ничего в конфигурации владельца не менять** — задание исследовательское, решение принимает он.
|
|
||||||
|
|
||||||
## P0-5. Аудит вторым проходом
|
|
||||||
|
|
||||||
В A38 выдумка прошла первый проход целиком. Здесь она — главный предмет проверки.
|
|
||||||
|
|
||||||
1. **Для каждой строки убедиться, что файл существует**: пройти `stat` по всем путям и сверить размеры с таблицей.
|
|
||||||
2. **Ни одной записи `COMPLETED` без файла** в результатах.
|
|
||||||
3. **Проверить выборочно тайминги**: повторить два-три замера и убедиться, что цифры воспроизводятся.
|
|
||||||
4. **Убедиться, что замер на 64К действительно сделан**, а не подменён замером на 32К.
|
|
||||||
5. **Проверить, что при каждом числе указан контекст**, и что VRAM снята по процессу.
|
|
||||||
6. **Убедиться, что файлы A44 не тронуты.**
|
|
||||||
7. **Побочные изменения** объяснить.
|
|
||||||
8. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- **Выполнять после A44**: видеокарта одна.
|
|
||||||
- Служебные юниты `qwen-coder` и `qwen-compressor` после прогонов вернуть в рабочее состояние: владелец пользуется сервером ежедневно.
|
|
||||||
- Конфигурацию хаба не менять.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Место на диске контролировать, раздел не забить.
|
|
||||||
- Версию `0.1.1` не поднимать, тег не создавать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Три кандидата прогнаны либо честно объявлены недоступными с причиной.
|
|
||||||
3. Для каждой строки: путь, размер в байтах, контрольная сумма, `general.name`, сырые тайминги.
|
|
||||||
4. **Для каждой модели есть замер на 64К контекста**; где не влезло или деградировало — с числами.
|
|
||||||
5. Проверено, повторяется ли у MoE провал на длинном контексте, замеченный у DeepSeek.
|
|
||||||
6. При каждом числе указан размер контекста; VRAM снята по процессу.
|
|
||||||
7. Отклонение по квантованию у Tiel оговорено.
|
|
||||||
8. Отчёт в отдельном файле; `BENCHMARK_REPORT.md` и `benchmark_results.json` не изменены.
|
|
||||||
9. Служебные модели на портах 8081 и 8082 возвращены в рабочее состояние.
|
|
||||||
10. Конфигурация владельца не изменена.
|
|
||||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Нынешний лидер по замерам — Qwen2.5-Coder-14B: 75% качества при 55,9 ток/с. Вопрос владельца в том, есть ли что-то заметно лучше. Qwen3-Coder-30B-A3B — самый обоснованный ответ, какой можно дать не запуская: три активных миллиарда на памяти-узком-месте должны дать скорость малой модели при качестве большой. Но ровно это же обещал DeepSeek, а на длинном контексте провалился до 3,35 ток/с. Поэтому замер на 64К здесь важнее самой таблицы скоростей.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
> **ОТМЕНЕНО 31.08.2026.** Работа передана Codex заданием
|
|
||||||
> `2026-08-31-A48-codex-interface-by-mockup.md`. Исполнитель сидит на сервере,
|
|
||||||
> где запущен хаб, и может сверять с макетом глазами. Antigravity за фронтенд
|
|
||||||
> не берётся: два агента в одних файлах уже приводили к переключению ветки под
|
|
||||||
> чужой работой.
|
|
||||||
|
|
||||||
# Задание A46: вёрстка по макетам — возврат по A43
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`81a58f6`) — там уже лежит A43.
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a46-mockup-redo origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-4** написан для аудитора.
|
|
||||||
|
|
||||||
Исполнитель работает на машине владельца, где хаб запущен и есть живой снапшот. Макеты — в `Desktop/фронтенд/`, файлы `1.1.png` … `7.1.png` и пояснения `1.txt`, `3.txt`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Почему возврат
|
|
||||||
|
|
||||||
A43 закрыл холст и оставил вёрстку нетронутой. Владелец поставил сборку и написал: «интерфейс вообще не изменился, какой был, такой и остался. Зачем тогда было задание на изменение по макетам?»
|
|
||||||
|
|
||||||
Он прав. Вот что изменил A43:
|
|
||||||
|
|
||||||
```
|
|
||||||
app.js +297 шесть карточек показателей, обвязка холста
|
|
||||||
workflow.js +136 панорамирование, зум колесом
|
|
||||||
index.html +8
|
|
||||||
workflow.css 15 потолок 430 px снят, слои холста
|
|
||||||
style.css НЕ ОТКРЫВАЛСЯ НИ РАЗУ
|
|
||||||
```
|
|
||||||
|
|
||||||
`style.css` — это и есть внешний вид: сетка, отступы, типографика, карточки, палитра. Пункт P0-2 задания A43 требовал привести экраны к макетам именно по этим свойствам. Сделать это, не тронув файл со стилями, невозможно.
|
|
||||||
|
|
||||||
Скриншотов «до и после», которых требовал критерий приёмки 3, в ветке нет. Работа была принята по прохождению тестов, а тесты внешний вид не проверяют.
|
|
||||||
|
|
||||||
**Холст переделывать не нужно.** P0-1 выполнен: панорамирование, зум колесом, снятый потолок высоты — всё работает и остаётся.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Расхождения с макетом `1.1.png`
|
|
||||||
|
|
||||||
Сверено ревьюером: макет против живой сборки `81a58f6` на сервере владельца.
|
|
||||||
|
|
||||||
**Шапка.** В макете: поиск с подсказкой `Ctrl + K`, колокольчик со счётчиком, шестерёнка, карточка пользователя (инициалы, имя, команда). В сборке вместо этого две кнопки — «Обновить всё» и «Добавить аккаунт».
|
|
||||||
|
|
||||||
**Логотип и подпись.** В макете вензель, «HERMES HUB» и вторая строка «Крона • Бизнес-экосистема». В сборке — значок молнии и «Multi-Account Router».
|
|
||||||
|
|
||||||
**Левое меню.** В макете иной набор и оформление пунктов, внизу блок с эмблемой и текстом про единый визуальный язык. В сборке внизу — служебная строка про Live API и номер сборки.
|
|
||||||
|
|
||||||
**Панель инструментов холста.** В макете вертикальная панель слева внутри холста: курсор, добавить узел, связь, рамка, показать, удалить. В сборке отсутствует полностью.
|
|
||||||
|
|
||||||
**Карточки узлов.** В макете: иконка, имя, файл `.md`, строка «модель • аккаунт», статус точкой. В сборке: три строки текста, обрезанные многоточием, без модели и аккаунта.
|
|
||||||
|
|
||||||
**Подписи связей.** В макете подписи `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED` разнесены и подкрашены, возвраты идут красным пунктиром. В сборке подписи налезают на карточки узлов и режутся: владелец видит «ТАНОВКА ЗАД» и «РАЙПРИЁМКА».
|
|
||||||
|
|
||||||
Центрирование подписей ревьюер уже починил (`text-anchor` в `workflow.css`), и эта правка на месте. Режут их **сами карточки узлов, которые рисуются поверх**, и слишком узкий промежуток между узлами. Лечится порядком слоёв и расстоянием в раскладке, а не стилем текста.
|
|
||||||
|
|
||||||
**Нижний ряд.** В макете три карточки: «Последние события» с временем и цветными бейджами, «Статистика workflow» с кольцевой диаграммой, «Активные задачи». В сборке — «Последние события» и «Управление LIVE».
|
|
||||||
|
|
||||||
**Инспектор.** В макете это основная панель: вкладки «Основное», «Модель», «Инструкции», «Инструменты», «Память», «История»; поля статуса, текущей задачи, итерации, последнего запуска, времени выполнения, успешности; блок «Конфигурация исполнения» с провайдером, аккаунтом, моделью, температурой, лимитом токенов и таймаутом; блок «Agent File»; инструменты чипами; быстрые действия кнопками. В сборке — пустая заглушка «Выберите агента на графе».
|
|
||||||
|
|
||||||
**Палитра и рамки.** Тёмно-зелёный фон с золотыми акцентами и тонкими рамками. Это то, что задаётся в `style.css`.
|
|
||||||
|
|
||||||
## P0-2. Что делать с элементами, которых нечем наполнить
|
|
||||||
|
|
||||||
Часть макета опирается на данные, которых в снапшоте может не быть.
|
|
||||||
|
|
||||||
1. **Ничего не выдумывать.** Нет данных — `Н/Д` с причиной, как принято в проекте. Нарисовать кольцевую диаграмму с числом «42» из макета — дефект, а не выполнение задания.
|
|
||||||
2. **Модель и аккаунт на карточке узла в снапшоте есть** — брать оттуда, а не подписывать примерами из макета.
|
|
||||||
3. **Версию из макета не переносить.** Там `v2.9.0`, в проекте `0.1.1`, и она заморожена намеренно.
|
|
||||||
4. **Нижняя панель других приложений экосистемы** (Planner, Journal, Finance и прочие) — этих приложений не существует. Не делать и **назвать пропущенным** в отчёте, а не рисовать неработающие кнопки.
|
|
||||||
5. Всё остальное, что упирается в отсутствующие данные, — так же: реализовать оформление, показать пустое состояние честно, перечислить в отчёте.
|
|
||||||
|
|
||||||
## P0-3. Скриншоты — это и есть сдача работы
|
|
||||||
|
|
||||||
Владелец сравнивает глазами. Отчёт без картинок принят не будет.
|
|
||||||
|
|
||||||
1. **Список расхождений по каждому экрану** — до начала работы, приложить.
|
|
||||||
2. **Скриншот до и после по каждому экрану** — обязательно. Это критерий, по которому A43 провалился.
|
|
||||||
3. **Рядом с каждой парой — фрагмент макета**, к которому приводили.
|
|
||||||
4. Пройти все макеты `1.1` … `7.1`, а не только первый.
|
|
||||||
5. **Три темы** — светлая, средняя, тёмная — проверить на каждом экране.
|
|
||||||
|
|
||||||
## P0-4. Аудит вторым проходом
|
|
||||||
|
|
||||||
Проверяющему: в прошлый раз работа была принята без единого взгляда на экран.
|
|
||||||
|
|
||||||
1. **Открыть хаб и посмотреть.** Не отчёт, не тесты — экран.
|
|
||||||
2. **Проверить, что `style.css` действительно изменён** и изменения относятся к вёрстке, а не косметике в одну строку.
|
|
||||||
3. **Сверить скриншоты с макетами** попарно.
|
|
||||||
4. **Убедиться, что выдуманных данных нет**: ни одного числа из макета в живом интерфейсе.
|
|
||||||
5. **Холст не сломан**: панорамирование, зум колесом, «вписать» работают как после A43.
|
|
||||||
6. **Подписи связей читаются** на всех масштабах и не перекрываются карточками.
|
|
||||||
7. **Побочные изменения** объяснить.
|
|
||||||
8. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1.
|
|
||||||
- Python не трогать.
|
|
||||||
- Холст A43 не переделывать, только доводить.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. `style.css` изменён; вёрстка экранов приведена к макетам.
|
|
||||||
3. По каждому экрану приложены скриншоты до и после рядом с фрагментом макета.
|
|
||||||
4. Пройдены все макеты `1.1` … `7.1`.
|
|
||||||
5. Инспектор агента реализован по макету; отсутствующие данные показаны как `Н/Д` с причиной.
|
|
||||||
6. Карточки узлов показывают модель и аккаунт из снапшота.
|
|
||||||
7. Подписи связей не перекрываются карточками узлов; проверено на нескольких масштабах.
|
|
||||||
8. Панель инструментов холста реализована либо названа пропущенной с причиной.
|
|
||||||
9. Ни одного числа из макета в живом интерфейсе.
|
|
||||||
10. Три темы работают на всех экранах.
|
|
||||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Владелец полдня ждал сборку, поставил её на две машины и увидел прежний интерфейс. Холст стал лучше, но он один экран из семи. Задание закрывает то, что в A43 просто не начинали: вёрстку по макетам. И сдаётся оно скриншотами, потому что проверить его иначе нельзя — тесты этого не видят, что и показал прошлый заход.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
# Задание A47: единая память для всех агентов сервера
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`80aab00`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a47-shared-memory origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
|
|
||||||
|
|
||||||
Задание идёт **на сервере** `192.168.1.81`. Правки кода — через git, правки памяти — прямо в хранилище (оно вне git, см. P0-2).
|
|
||||||
|
|
||||||
Не пересекается с A42 (провайдеры), A46 (вёрстка), A45 (замеры).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец: «чтобы он работал совместно с обсидиан, чтобы все ИИ на сервере использовали единый мозг».
|
|
||||||
|
|
||||||
Хранилище уже есть и сделано хорошо. Задание — не строить его заново, а заставить работать: сейчас им никто не пользуется.
|
|
||||||
|
|
||||||
## Что проверено ревьюером
|
|
||||||
|
|
||||||
**Хранилище.** `/srv/projects/AI-Memory`, Obsidian 1.13.7 из snap, 218 заметок, 2,7 МБ. Структура `00_SYSTEM`, `01_PROJECTS`, `02_KNOWLEDGE`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, `99_ARCHIVE`. Заметки размечены полями (`type`, `severity`, `confidence`, `created_by`, `reviewed_by`). Есть протокол, политика памяти, роли и три шаблона.
|
|
||||||
|
|
||||||
Приложение Obsidian нужно человеку. **Агенту достаточно пути**: хранилище — это папка с файлами Markdown, никаких плагинов и серверов поднимать не надо.
|
|
||||||
|
|
||||||
**Память проекта устарела на четыре дня и тринадцать заданий.**
|
|
||||||
|
|
||||||
```
|
|
||||||
01_PROJECTS/hermes-hub/CURRENT_STATE.md обновлён 27 августа
|
|
||||||
в нём: main = c35bc48, идёт работа над A33
|
|
||||||
на деле: main = 80aab00, идёт A46
|
|
||||||
worklog/ ПУСТО, 0 записей
|
|
||||||
```
|
|
||||||
|
|
||||||
Протокол требует после каждой задачи обновить `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md` и написать worklog. Не делалось ни разу.
|
|
||||||
|
|
||||||
**Мосты.** В репозиториях `agent-control-center`, `business-platform`, `finance-*`, `hermes-android` и других `AGENTS.md` есть и на AI-Memory ссылается. Исключением был `hermes-hub`; корневой мост добавлен ревьюером в `80aab00`, но **на сервере лежит старая копия** — нужен `git pull`. Без моста также `hermes-hub-a34`.
|
|
||||||
|
|
||||||
**Хранилище не под контролем версий.** `.git` нет, истории нет, отката нет. При этом папка доступна на запись нескольким агентам сразу.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Привести память проекта в соответствие с действительностью
|
|
||||||
|
|
||||||
Не переписывать заново — обновить.
|
|
||||||
|
|
||||||
1. `CURRENT_STATE.md`: текущий `main`, ветки в работе, что сделано за A40–A46, что открыто.
|
|
||||||
2. `HANDOFF.md`: с чего продолжать.
|
|
||||||
3. `TASKS.md`: состояние заданий A40–A47.
|
|
||||||
4. `DECISIONS.md`: решения, принятые за эти дни, — отказ от `screenshot-to-code` и почему; клиент без сборки; версия `0.1.1` заморожена намеренно; порог 64К у Hermes.
|
|
||||||
5. **Даты и коммиты обязательны** у каждой записи. Память без даты нельзя отличить от свежей, и агент поверит устаревшей.
|
|
||||||
|
|
||||||
Уроки за эти дни оформить по `LESSON_TEMPLATE.md`, минимум эти:
|
|
||||||
|
|
||||||
```
|
|
||||||
мнимый успех: действие вернуло ok:True и не сохранило ничего
|
|
||||||
клиент читал несуществующий ключ снапшота (profiles вместо all_profiles)
|
|
||||||
заглушка sleep 3600 вместо llama-server оставлена на рабочей машине
|
|
||||||
работа принята по зелёным тестам без единого взгляда на экран
|
|
||||||
зашитые идентификаторы профилей: убраны в A41, возвращены в A42
|
|
||||||
```
|
|
||||||
|
|
||||||
## P0-2. Хранилище под контроль версий
|
|
||||||
|
|
||||||
Сейчас это папка без истории, куда пишут несколько агентов. Одна ошибочная перезапись — и восстановить нечем.
|
|
||||||
|
|
||||||
1. Завести git **локально**, без публичного удалённого репозитория: в памяти обсуждается внутреннее устройство систем владельца.
|
|
||||||
2. `.gitignore` для служебного каталога `.obsidian/workspace*` и прочего, что меняется от открытия окна.
|
|
||||||
3. Ежедневный коммит-снимок либо коммит после изменений — на выбор, но обосновать.
|
|
||||||
4. **Проверить восстановление**: испортить копию файла, вернуть из истории.
|
|
||||||
5. Учётные данные, токены и пути к ним в память не писать — проверить, что их там нет уже сейчас.
|
|
||||||
|
|
||||||
## P0-3. Единый мозг: все агенты читают одно
|
|
||||||
|
|
||||||
Смысл в том, чтобы урок, полученный одним агентом, работал у остальных.
|
|
||||||
|
|
||||||
1. **Составить перечень**, какие ИИ действительно работают на сервере и каким файлом каждый настраивается. У разных инструментов это разные имена (`AGENTS.md`, `CLAUDE.md` и другие) — выяснить, а не предположить.
|
|
||||||
2. **Каждому дать мост** на `/srv/projects/AI-Memory` по образцу `00_SYSTEM/AGENTS_BRIDGE_PLAN.md`: короткий указатель, без копий уроков.
|
|
||||||
3. **Обновить копию `hermes-hub` на сервере** (`git pull`), чтобы корневой мост из `80aab00` там появился.
|
|
||||||
4. **`hermes-hub-a34`** — выяснить, живой ли это рабочий каталог. Если остаток — убрать; если рабочий — дать мост.
|
|
||||||
5. **Правило единственности.** Уроки и решения живут только в AI-Memory. Копия в репозитории — дефект: копии расходятся, и агент читает неверную.
|
|
||||||
|
|
||||||
## P0-4. Обновление памяти — часть завершения задачи
|
|
||||||
|
|
||||||
Иначе всё вернётся к нынешнему состоянию.
|
|
||||||
|
|
||||||
1. Внести в шаблон задания два обязательных пункта: **прочитать память до работы**, **обновить после**.
|
|
||||||
2. В отчёт добавить строку: какие файлы памяти обновлены и каким уроком пополнилась база.
|
|
||||||
3. **Проверка свежести**: способ увидеть, что `CURRENT_STATE.md` отстал от `main`. Достаточно скрипта, сравнивающего записанный коммит с текущим, и предупреждения при расхождении.
|
|
||||||
4. **Всё хранилище в контекст не загружать** — 218 заметок. Читать `00_SYSTEM`, файлы своего проекта и найденное поиском по теме.
|
|
||||||
|
|
||||||
## P0-5. Несколько агентов пишут одновременно
|
|
||||||
|
|
||||||
Общая папка на запись без разграничения уже дала в этом проекте два случая: агент переключил ветку под чужой работой, и на рабочей машине осталась подменённая заглушка.
|
|
||||||
|
|
||||||
1. **Одновременная запись в один файл не должна терять правки.** Предложить механизм и обосновать: раздельные файлы worklog на агента, дозапись вместо перезаписи, блокировка.
|
|
||||||
2. **Каждая запись подписана**: кто, когда, по какому заданию. Поле `created_by` в шаблоне уже есть — использовать.
|
|
||||||
3. **Чужие записи не переписывать.** Не согласен — добавить свою и сослаться на исходную.
|
|
||||||
|
|
||||||
## P0-6. Граница: память — это данные, а не канал команд
|
|
||||||
|
|
||||||
Отдельным пунктом, потому что цена ошибки высока и проект этим уже занимался в A37.
|
|
||||||
|
|
||||||
Общая папка, из которой все агенты читают инструкции и в которую все пишут, — это ровно тот канал связи между агентами, о котором предупреждал разбор чужого инцидента, приложенный к A37: разрешённый внутренний сервис становится доской объявлений и точкой опоры.
|
|
||||||
|
|
||||||
1. **Память описывает состояние и уроки. Она не отдаёт распоряжений.** Задания приходят от владельца через `agents/inbox/`, а не из заметок.
|
|
||||||
2. **Заметка, требующая действия, исполнением не является.** Найденный в памяти «TODO» выносится владельцу, а не выполняется молча.
|
|
||||||
3. **Изменения, расширяющие права или меняющие правила работы агентов**, вносит владелец. Агент может предложить.
|
|
||||||
4. Подписи и даты из P0-5 нужны и для этого: должно быть видно, кто внёс запись.
|
|
||||||
|
|
||||||
## P0-7. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Сверить `CURRENT_STATE.md` с действительностью**: коммит в памяти против `git log` на сервере.
|
|
||||||
2. **Проверить восстановление из истории** самостоятельно, а не по описанию.
|
|
||||||
3. **Проверить, что мост есть у каждого перечисленного агента** и указывает на существующий путь.
|
|
||||||
4. **Искать копии уроков** в репозиториях — их быть не должно.
|
|
||||||
5. **Искать учётные данные** в памяти целенаправленно.
|
|
||||||
6. **Проверить, что заметки не отдают распоряжений** агентам.
|
|
||||||
7. **Побочные изменения** объяснить.
|
|
||||||
8. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Хранилище **не публиковать**: ни на GitHub, ни куда-либо ещё.
|
|
||||||
- Существующие заметки владельца не удалять и не переписывать; устаревшее переносить в `99_ARCHIVE`.
|
|
||||||
- Структуру папок и разметку полей не менять — она рабочая.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Службы `qwen-coder` и `qwen-compressor` не трогать — они только что восстановлены.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md`, `DECISIONS.md` проекта соответствуют действительности; у записей есть даты и коммиты.
|
|
||||||
3. Уроки из P0-1 оформлены по шаблону.
|
|
||||||
4. Хранилище под git локально; восстановление файла из истории проверено, вывод приложен.
|
|
||||||
5. Перечень ИИ сервера составлен; у каждого мост на AI-Memory; пути существуют.
|
|
||||||
6. Копия `hermes-hub` на сервере обновлена, корневой мост на месте; судьба `hermes-hub-a34` решена.
|
|
||||||
7. Копий уроков в репозиториях нет.
|
|
||||||
8. Шаблон задания содержит пункты про чтение и обновление памяти.
|
|
||||||
9. Проверка свежести работает: расхождение памяти с `main` обнаруживается.
|
|
||||||
10. Механизм одновременной записи предложен, обоснован и проверен.
|
|
||||||
11. Учётных данных в памяти нет; проверено поиском.
|
|
||||||
12. `ruff check .` чисто; релизный гейт не ухудшен.
|
|
||||||
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Память построена, размечена и продумана — а последняя запись в ней сделана 27 августа, и каталог worklog пуст. Тринадцать заданий прошли мимо. Агент, который добросовестно её прочитает, начнёт работать по состоянию четырёхдневной давности: решит, что идёт A33 и `main` — это `c35bc48`. Устаревшая память вреднее отсутствующей, потому что ей верят.
|
|
||||||
|
|
||||||
Задание про то, чтобы память стала живой: обновлялась как часть работы, была одинаково видна всем агентам и пережила ошибочную перезапись.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
# Задание A49: расстановка субагентов, вкладка «Скиллы», память через Obsidian
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`17b368a`) — туда слиты A42, A45, A47 и A48.
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a49-subagents-skills-memory origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
|
||||||
|
|
||||||
Задание крупное и делится на три независимые части. **Части можно сдавать по отдельности**, но каждую — целиком.
|
|
||||||
|
|
||||||
Не пересекается с A44 (сервер) и A45 (замеры). Вёрстка A48 уже в `main`: новые экраны делать в её стиле, существующие не ломать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что проверено ревьюером
|
|
||||||
|
|
||||||
**Ролей объявлено тринадцать**, соединено пять.
|
|
||||||
|
|
||||||
```
|
|
||||||
manager developer-1 developer-2 code-reviewer researcher tester
|
|
||||||
tech-writer analyst guardian cost-controller integration-expert
|
|
||||||
security-expert dependency-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
Конвейер по умолчанию связывает только `manager → developer-1 → developer-2 → code-reviewer` с возвратами по `REVIEW_FAILED`. Остальные восемь ролей объявлены, но в графе висят без связей: на экране владельца `Research` и `Fast` стоят в стороне и ни к чему не присоединены.
|
|
||||||
|
|
||||||
**Скиллов в интерфейсе нет вовсе.** Ни вкладки, ни поля в инспекторе агента, ни признака, пользовался ли агент скиллом.
|
|
||||||
|
|
||||||
**Общая память уже работает** после A47: `/srv/projects/AI-Memory` под git, структура `00_SYSTEM`, `01_PROJECTS`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, протокол и шаблоны на месте, `worklog` заполняется. Корневой `AGENTS.md` в репозитории указывает на неё.
|
|
||||||
|
|
||||||
**Obsidian** стоит на сервере (snap 1.13.7), но **агенту он не нужен**. Из руководства владельца по подключению Obsidian к агенту, дословно: «Агенту нужен не GUI Obsidian, а локальная папка vault». Хранилище — это папка с файлами Markdown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 1. Расстановка субагентов и связи
|
|
||||||
|
|
||||||
## P0-1. Разобрать всех тринадцать и соединить
|
|
||||||
|
|
||||||
1. **Разбор каждой роли**: что делает, от кого получает работу, кому передаёт, по какому условию. Приложить таблицей.
|
|
||||||
2. **Связать те, что должны работать вместе.** Восемь ролей сейчас ни с чем не соединены — для каждой либо связь, либо явная запись «работает по вызову, в конвейер не входит» с обоснованием.
|
|
||||||
3. **Условия переходов** брать из существующего набора: `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`, `NEXT`, `ERROR`, `ALWAYS`. Новые вводить только при необходимости и объяснять.
|
|
||||||
4. **Циклы доработки конечны.** Возврат `REVIEW_FAILED` без ограничения числа итераций — это бесконечный круг на живых квотах. Предел итераций уже есть в конвейере — проверить, что он соблюдается на каждом возврате.
|
|
||||||
5. **Расстановка на холсте осмысленная**: поток слева направо, возвраты видимой дугой, узлы не наезжают друг на друга. После A48 подписи связей читаются — не сломать.
|
|
||||||
|
|
||||||
**Ничего не выдумывать про роли.** Назначение брать из `role_registry.py`; если для роли нет внятного места в потоке, так и написать, а не придумывать ей работу.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 2. Вкладка «Скиллы»
|
|
||||||
|
|
||||||
## P0-2. Скиллы видны, ищутся и назначаются
|
|
||||||
|
|
||||||
1. **Новая вкладка «Скиллы»** в главном меню, в стиле экранов A48.
|
|
||||||
2. **Список установленных скиллов** — читать из каталога скиллов агента (`~/.claude/skills/` и равнозначные для других инструментов; путь настраивается). Показывать `name`, `description` и путь.
|
|
||||||
3. **Поиск** по имени и описанию.
|
|
||||||
4. **Назначение скилла субагенту** — из вкладки и из карточки агента. Назначения сохраняются и переживают перезапуск.
|
|
||||||
5. **Во вкладке «Инструменты» инспектора** показывать назначенные скиллы. Сейчас там `Н/Д: инструменты не назначены` — это состояние должно наполниться.
|
|
||||||
6. **Скилл не найден или каталог отсутствует** — сказать об этом с причиной и путём, где искали. Не показывать пустой список как «скиллов нет».
|
|
||||||
|
|
||||||
## P0-3. Видно, пользовался ли агент скиллом
|
|
||||||
|
|
||||||
Владелец: «добавить режим просмотра, использовал он в проекте скиллы или сам придумывал».
|
|
||||||
|
|
||||||
1. **Записывать факт применения**: какой скилл, каким агентом, в какой задаче, когда.
|
|
||||||
2. **Показывать в истории агента** и отдельным срезом по проекту: применённые скиллы против назначенных, но ни разу не сработавших.
|
|
||||||
3. **Назначен и ни разу не применён — это сигнал**, а не ошибка. Показывать как факт: скилл может не подходить под задачи, а может быть сломан — второе лечится частью P0-4.
|
|
||||||
4. **Правило честности здесь особенно важно.** Если признак применения снять неоткуда — писать `Н/Д` с причиной, а не рисовать правдоподобную статистику. Сначала выяснить, что вообще можно узнать достоверно, и в отчёте назвать источник.
|
|
||||||
|
|
||||||
## P0-4. Субагент «скилл-доктор»
|
|
||||||
|
|
||||||
Готовый скилл лежит у владельца: `Desktop/skills-hermes/skill-doctor/` — `SKILL.md` и `references/description-cookbook.md`. **Написан, выверен и переделке не подлежит**; задание — встроить его как роль.
|
|
||||||
|
|
||||||
Главное из него, что определяет устройство роли:
|
|
||||||
|
|
||||||
- **У скилла две независимые части.** `frontmatter` (`name`, `description`) решает, **запустится** ли скилл; тело решает, **что будет после запуска**. Чинить тело, когда сломано описание, — самая частая потеря времени.
|
|
||||||
- **Порядок диагностики:** формальное (имя файла ровно `SKILL.md`, расположение, границы `---`, `name` латиницей, `description` одной строкой) → разбор описания на три части → тело → проверочные запросы → диагноз.
|
|
||||||
- **Многострочный `description` — ошибка номер один по частоте**: YAML обрезает его, и решение о запуске принимается по огрызку.
|
|
||||||
- **Описание состоит из трёх частей**: что делает, когда запускать (реальными словами пользователя, 4–5 формулировок), когда **НЕ** запускать. Третья отсутствует почти всегда, и без неё скилл тихо срабатывает на соседних темах и жжёт лимиты — это хуже молчания, потому что не замечается.
|
|
||||||
- **Пять проверочных запросов**: три должны запустить скилл, два — не запустить. Негативные обязательны.
|
|
||||||
- **Диагноз выдаётся строгим форматом** с готовым `description` целиком, а не советом «сделай понятнее».
|
|
||||||
|
|
||||||
Требования к встраиванию:
|
|
||||||
|
|
||||||
1. **Новая каноническая роль** `skill-doctor` в реестре, с назначением и способностями, как у остальных.
|
|
||||||
2. **Запуск из вкладки «Скиллы»**: кнопка «Проверить скилл» рядом с каждым, и общая проверка всех.
|
|
||||||
3. **Результат показывать в интерфейсе** тем же форматом диагноза, с готовым описанием, которое можно скопировать.
|
|
||||||
4. **Скилл-доктор не правит файлы молча.** Он ставит диагноз и предлагает правку; применяет её владелец.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 3. Память через Obsidian
|
|
||||||
|
|
||||||
## P0-5. Хранилище подключается и наполняется
|
|
||||||
|
|
||||||
Владелец: «если на ПК или сервере установлен Обсидиан, то должен подгружаться в память… в настройках добавляешь папку рабочую Обсидиан, и оркестратору даёшь задание, чтобы он настроил работу».
|
|
||||||
|
|
||||||
1. **Обнаружение.** Хаб проверяет, есть ли Obsidian и хранилище. Признак хранилища — **папка с каталогом `.obsidian` внутри**, а не установленное приложение: агенту нужна папка, не программа. Найдено — предложить; не найдено — сказать прямо, без догадок.
|
|
||||||
2. **Настройка пути** в «Настройках»: путь к хранилищу задаётся вручную и сохраняется. На сервере владельца это `/srv/projects/AI-Memory`.
|
|
||||||
3. **Проверка при сохранении**: путь существует, доступен на запись, внутри есть `.obsidian`. Иначе — отказ с причиной.
|
|
||||||
4. **Хранилища нет — хаб работает как прежде.** Память не должна стать обязательной.
|
|
||||||
|
|
||||||
## P0-6. Оркестратор раскладывает память по структуре
|
|
||||||
|
|
||||||
1. **Действие «Настроить память»**, запускающее оркестратора по заложенной структуре. Структура **уже существует** — та, что в `/srv/projects/AI-Memory`: `00_SYSTEM`, `01_PROJECTS/<проект>/`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`. Использовать её, а не изобретать вторую.
|
|
||||||
2. **У каждого субагента во вкладке «Память»** — своя структура по проектам: что он читает перед работой, что записывает после, его записи в `worklog` и его уроки.
|
|
||||||
3. **Существующие заметки владельца не трогать.** 218 заметок и восемь записей `worklog` уже есть; устаревшее переносить в `99_ARCHIVE`, не удалять.
|
|
||||||
4. **Разделение чтения и записи.** Субагент читает общее, пишет своё. Каждая запись подписана: кто, когда, по какому заданию.
|
|
||||||
5. **Граница остаётся.** Память описывает состояние и уроки; **распоряжений она не отдаёт**. Задание приходит от владельца, а не из заметки. Это требование A47, и оно не отменяется тем, что памятью теперь управляет оркестратор.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-7. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Открыть хаб и посмотреть** новую вкладку и связи на холсте. Не отчёт — экран. Скриншоты приложить, как в A48.
|
|
||||||
2. **Проверить, что список скиллов настоящий**: подложить скилл в каталог и убедиться, что он появился; убрать — исчез.
|
|
||||||
3. **Скилл-доктор проверить на заведомо сломанном скилле** — с многострочным `description` — и убедиться, что диагноз указывает именно на это.
|
|
||||||
4. **Признак применения скилла**: убедиться, что он снимается измерением, а не выводится из назначения.
|
|
||||||
5. **Проверить, что без Obsidian хаб работает** как прежде.
|
|
||||||
6. **Проверить, что заметки владельца не пострадали**: число заметок до и после.
|
|
||||||
7. **Циклы доработки конечны** — убедиться, что предел итераций соблюдается.
|
|
||||||
8. **Побочные изменения** объяснить.
|
|
||||||
9. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Клиент **без сборки, без npm, без фреймворка**.
|
|
||||||
- Вёрстку A48 не ломать; новые экраны — в её стиле.
|
|
||||||
- Учётные данные, `~/.hermes/agy_profiles/`, службы `qwen-coder` и `qwen-compressor` не трогать.
|
|
||||||
- Заметки владельца не удалять.
|
|
||||||
- Скилл-доктор из `Desktop/skills-hermes/skill-doctor/` не переписывать.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений: не измерено — `Н/Д` с причиной.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Разбор тринадцати ролей приложен таблицей; каждая либо соединена, либо объявлена внеконвейерной с обоснованием.
|
|
||||||
3. Циклы доработки конечны; проверено.
|
|
||||||
4. Вкладка «Скиллы» есть: список читается из каталога, поиск работает, назначение сохраняется и переживает перезапуск.
|
|
||||||
5. Назначенные скиллы видны в инспекторе агента.
|
|
||||||
6. Видно, применялся ли скилл; источник признака назван; неизмеримое помечено `Н/Д`.
|
|
||||||
7. Роль `skill-doctor` в реестре; запуск из интерфейса; диагноз выводится строгим форматом с готовым описанием; файлы молча не правятся.
|
|
||||||
8. Скилл-доктор проверен на заведомо сломанном скилле.
|
|
||||||
9. Хранилище Obsidian обнаруживается по наличию `.obsidian`, путь настраивается и проверяется.
|
|
||||||
10. Без хранилища хаб работает как прежде.
|
|
||||||
11. Оркестратор раскладывает память по существующей структуре; у каждого субагента во вкладке «Память» видна структура по проектам.
|
|
||||||
12. Заметки владельца целы; число до и после совпадает.
|
|
||||||
13. Скриншоты новых экранов приложены.
|
|
||||||
14. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
|
||||||
15. Память проекта в AI-Memory обновлена.
|
|
||||||
16. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Тринадцать субагентов объявлено, работают пятеро, восемь висят на холсте без связей. Скиллы владелец ставит руками и не видит ни списка, ни того, пользовался ими агент или писал по наитию. Память после A47 ожила, но субагенты в неё не смотрят.
|
|
||||||
|
|
||||||
Задание сводит три вещи в одно: агенты расставлены и связаны осмысленно, у каждого свои скиллы с проверкой их исправности, и все читают одну память по структуре, которую раскладывает оркестратор.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# Задание A51: подключённый аккаунт должен реально использоваться Hermes
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`17b368a`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a51-hub-controls-hermes origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
|
||||||
|
|
||||||
Зона: маршрутизация, назначение ролей, плагин Hermes, карточка аккаунта. С A49 (скиллы и память) и A50 (обнаружение и проверка) не пересекается по смыслу, но **трогает те же файлы, что A50** — `auto_assigner.py` и экран «Аккаунты». Выполнять **после A50**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец: «в хабе я поставил аккаунт аги. Когда я захожу в Гермеса, какой аккаунт будет выбран? И если я поменяю в Гермесе аккаунт, поменяется он в хабе? Иначе толку от хаба, если в самом Гермесе это не работает».
|
|
||||||
|
|
||||||
Ответ, проверенный ревьюером: **сейчас не будет выбран ни один из его аккаунтов**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что проверено исполнением
|
|
||||||
|
|
||||||
**Определение роли работает.** A35 встал: плагин перехватывает каждый `llm_execution` и определяет роль четырьмя уровнями — явная, по модели и провайдеру, по устойчивости сессии, по умолчанию. Это переделке не подлежит.
|
|
||||||
|
|
||||||
**Но цепочки указывают в пустоту.** Конфигурация владельца на рабочей машине:
|
|
||||||
|
|
||||||
```
|
|
||||||
default_role: не задан → используется manager
|
|
||||||
manager → ag-orch-fallback, codex-orch, opengo-3
|
|
||||||
developer-1 → ag-w1, codex-worker-1, opengo-3
|
|
||||||
researcher → opengo-1, ag-w3, ag-w4
|
|
||||||
|
|
||||||
профилей в конфигурации: 24
|
|
||||||
ролей: 13
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка вхождения **подключённых** аккаунтов владельца в цепочки:
|
|
||||||
|
|
||||||
```
|
|
||||||
ollama-1 НИ В ОДНОЙ
|
|
||||||
grok-1 НИ В ОДНОЙ
|
|
||||||
local-2 НИ В ОДНОЙ
|
|
||||||
local-3 НИ В ОДНОЙ
|
|
||||||
antigravity-1 НИ В ОДНОЙ
|
|
||||||
```
|
|
||||||
|
|
||||||
Все тринадцать ролей по-прежнему ссылаются на заготовки из старой конфигурации на 24 слота, а они не настроены. Значит при обращении Hermes цепочка `manager` перебирает три неавторизованных слота, отказывает, и плагин — правильно, по своему устройству — **пропускает вызов мимо хаба дальше в Hermes**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if isinstance(completion, dict) and completion.get("router_error"):
|
|
||||||
logger.warning("Router failover exhausted for role %r; passing the call downstream to Hermes")
|
|
||||||
```
|
|
||||||
|
|
||||||
Хаб при этом ведёт себя корректно: он не подменяет ответ. Но результат для владельца тот самый, которого он опасается — **хаб не участвует в работе вовсе**.
|
|
||||||
|
|
||||||
**Карточка аккаунта показывает роль, которой нет.** На экране `ollama-1` подписан «manager (primary)», хотя в цепочке `manager` его нет. Источник подписи — `auto_assigner.get_display_name_and_role`, а она читает **статическую таблицу** `DEFAULT_SLOT_ROLES`, а при промахе достраивает подпись из имени провайдера. В снапшот это попадает так:
|
|
||||||
|
|
||||||
```python
|
|
||||||
assigned_roles=role_assignments.get(pid, [log_role])
|
|
||||||
```
|
|
||||||
|
|
||||||
Есть аккаунт в живой цепочке — берётся живое значение; нет — подставляется **догадка**. Владелец видит «manager (primary)» и считает, что аккаунт назначен.
|
|
||||||
|
|
||||||
Тот же аккаунт на карточке списка подписан «worker», а в окне — «manager (primary)». Два разных источника в двух местах.
|
|
||||||
|
|
||||||
**Обратной синхронизации нет.** `_select_model` в `hermes_plugin.py` — ручная команда CLI, которая записывает в конфигурацию Hermes провайдера, модель и адрес. Ничего, что читало бы выбор владельца, сделанный **внутри** Hermes, и переносило бы его в хаб, в коде нет.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Подключённый аккаунт попадает в цепочку
|
|
||||||
|
|
||||||
1. **Подключение аккаунта ставит его в цепочку выбранной роли.** Роль владелец выбирает на третьем шаге мастера — сейчас этот выбор до цепочки не доходит.
|
|
||||||
2. **Если аккаунт никуда не назначен — так и писать.** «Не назначен» — нормальное состояние, но оно должно быть видно, а не подменяться догадкой.
|
|
||||||
3. **Кнопка «Авто»**: разложить подключённые аккаунты по ролям по способностям провайдера. Предложить расстановку и **показать до применения**, а не применять молча.
|
|
||||||
|
|
||||||
## P0-2. Карточка показывает то, что есть на самом деле
|
|
||||||
|
|
||||||
1. **Роль на карточке берётся только из живой цепочки.** Подстановка из `DEFAULT_SLOT_ROLES` в качестве роли — убрать: статическая таблица годится для человекочитаемого имени, но не для утверждения о назначении.
|
|
||||||
2. **Один источник для карточки и окна.** Сейчас список пишет «worker», окно — «manager (primary)».
|
|
||||||
3. **Показывать место в цепочке**: основной или запасной номер такой-то. «primary» без указания, в какой роли и на каком месте, ничего не значит.
|
|
||||||
|
|
||||||
## P0-3. Владелец видит, кто ответит на вызов
|
|
||||||
|
|
||||||
Главное, ради чего задание.
|
|
||||||
|
|
||||||
1. **На экране маршрутизации у каждой роли — «сейчас ответит: <аккаунт>»**, вычисленное по текущей цепочке и состоянию аккаунтов.
|
|
||||||
2. **Если не ответит никто** — сказать прямо: «цепочка пуста или все аккаунты недоступны, вызов уйдёт мимо хаба в Hermes». Это состояние сейчас и есть, и владелец о нём не знает.
|
|
||||||
3. **Роль по умолчанию видна и настраивается.** Сейчас `default_role` не задан, и молча используется `manager`. Показать это в настройках.
|
|
||||||
|
|
||||||
## P0-4. Видно, прошёл вызов через хаб или мимо
|
|
||||||
|
|
||||||
1. **Записывать по каждому вызову**: определилась ли роль, каким уровнем, какой профиль выбран, ушёл ли вызов мимо хаба и почему.
|
|
||||||
2. **Показывать в журнале событий** и счётчиком на «Обзоре»: сколько вызовов прошло через хаб, сколько мимо.
|
|
||||||
3. Это единственный способ ответить на вопрос владельца «работает ли хаб» измерением, а не рассуждением.
|
|
||||||
|
|
||||||
## P0-5. Обратная связь с Hermes
|
|
||||||
|
|
||||||
Владелец: «если я поменяю в Гермесе аккаунт, поменяется он в хабе?»
|
|
||||||
|
|
||||||
Сейчас — нет. Прежде чем делать, **выяснить и записать в отчёт**, что именно Hermes позволяет наблюдать: что хранится в его конфигурации, меняется ли она при выборе модели в интерфейсе, есть ли событие или файл, по которому это видно.
|
|
||||||
|
|
||||||
Дальше по результату:
|
|
||||||
|
|
||||||
1. **Если выбор Hermes читается** — показывать его в хабе и отмечать расхождение с цепочкой: «в Hermes выбран X, хаб направил бы на Y».
|
|
||||||
2. **Если не читается** — так и написать, а в интерфейсе объяснить владельцу, что хаб управляет маршрутом только когда Hermes не задаёт провайдера явно. Честное объяснение принимается.
|
|
||||||
3. **Ничего не записывать в конфигурацию Hermes автоматически.** `_select_model` остаётся ручной командой: молчаливая правка чужой конфигурации — это то, за что уже возвращались работы.
|
|
||||||
4. **Не выдумывать механизм**, которого в Hermes нет. Отсутствие способа — результат, он принимается.
|
|
||||||
|
|
||||||
## P0-6. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Пройти путь целиком на живой машине**: подключить аккаунт, назначить роль, сделать запрос через Hermes и убедиться по журналу, что вызов пошёл через хаб и через **этот** аккаунт. Это единственная настоящая проверка задания.
|
|
||||||
2. **Проверить обратное**: убрать аккаунт из цепочки и убедиться, что вызов уходит мимо хаба и это видно в интерфейсе.
|
|
||||||
3. **Проверить, что роль на карточке исчезает**, когда аккаунт не назначен, — а не подменяется догадкой.
|
|
||||||
4. **Сверить карточку и окно**: подпись роли одинакова.
|
|
||||||
5. **Проверить конфигурацию владельца на копии**: старые цепочки на 24 заготовки не должны молча пропасть; предложить перенос, но не выполнять его без подтверждения.
|
|
||||||
6. **Побочные изменения** объяснить.
|
|
||||||
7. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Конфигурацию владельца молча не переписывать: перенос цепочек — только с подтверждением.
|
|
||||||
- В конфигурацию Hermes автоматически не писать.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Определение роли из A35 не переделывать.
|
|
||||||
- Вёрстку A48 не ломать.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Подключение аккаунта с выбором роли кладёт его в цепочку этой роли; проверено.
|
|
||||||
3. Неназначенный аккаунт показан как неназначенный; догадка из статической таблицы как роль не используется.
|
|
||||||
4. Карточка и окно аккаунта показывают одну и ту же роль и место в цепочке.
|
|
||||||
5. На маршрутизации видно, какой аккаунт ответит для каждой роли; пустая цепочка названа прямо.
|
|
||||||
6. Роль по умолчанию видна и настраивается.
|
|
||||||
7. По журналу видно, прошёл вызов через хаб или мимо и почему; есть счётчик.
|
|
||||||
8. Пройден живой путь: подключение, назначение, запрос через Hermes, подтверждение по журналу.
|
|
||||||
9. Выяснено и записано, что Hermes позволяет наблюдать о своём выборе; сделано либо честно объявлено невозможным.
|
|
||||||
10. Конфигурация владельца не изменена без подтверждения.
|
|
||||||
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
|
||||||
12. Память проекта в AI-Memory обновлена.
|
|
||||||
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Владелец подключил аккаунты, увидел на карточках «manager (primary)» и решил, что настроил маршрутизацию. На деле ни один его аккаунт не входит ни в одну цепочку: все тринадцать ролей ссылаются на заготовки старой конфигурации. При обращении Hermes цепочка отказывает, и вызов уходит мимо хаба.
|
|
||||||
|
|
||||||
Хаб ведёт себя корректно и не подменяет ответ. Но владелец об этом не знает и считает, что управляет маршрутизацией, а управляет пустотой. Задание должно сделать так, чтобы назначение действительно назначало, а расхождение было видно на экране, а не выяснялось разбором конфигурации.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
# Задание A52: локальные модели — замена, надзиратель, пара кодеров с облачным судьёй
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31 (переработано в тот же день: добавлены части 1 и 3)
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`17b368a`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a52-local-models origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-10** написан для аудитора.
|
|
||||||
|
|
||||||
Задание из трёх частей, и они идут **строго по порядку**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Часть 1 замена моделей и замеры сдаётся отдельно, дальше по её числам
|
|
||||||
Часть 2 надзиратель локальных моделей
|
|
||||||
Часть 3 пара кодеров и облачный судья
|
|
||||||
```
|
|
||||||
|
|
||||||
Часть 3 планировать **по измеренным числам части 1**, а не заранее: без замера видеопамяти схема не проверяема.
|
|
||||||
|
|
||||||
Связано с A49 (расстановка субагентов и память) и A51 (аккаунт реально используется). Выполнять после них: надзирателю нужны и место в графе, и работающее назначение.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что проверено ревьюером — заново не выяснять
|
|
||||||
|
|
||||||
### Видеопамять занята почти полностью
|
|
||||||
|
|
||||||
```
|
|
||||||
Qwen3.8-27B кодер @196K 25 488 МиБ
|
|
||||||
qwen3-4b компрессор @32K 5 368 МиБ
|
|
||||||
──────────
|
|
||||||
30 856 из 32 768 свободно 1 912
|
|
||||||
```
|
|
||||||
|
|
||||||
### Измерено у кандидатов (A45, файлы и контрольные суммы сверены по диску)
|
|
||||||
|
|
||||||
```
|
|
||||||
Qwen3-Coder-30B-A3B @64K 21 368 МиБ 109,6 ток/с 83,3% MoE 30B/3B
|
|
||||||
Qwen3-Coder-30B-A3B @32K 19 640 МиБ 110,2 ток/с
|
|
||||||
Qwen2.5-Coder-32B @64K 27 938 МиБ 29,3 ток/с 83,3% плотная
|
|
||||||
```
|
|
||||||
|
|
||||||
Расход контекста у Qwen3-Coder — около 54 КиБ на токен.
|
|
||||||
|
|
||||||
### Качество на стенде из 12 задач (A40, признано)
|
|
||||||
|
|
||||||
```
|
|
||||||
Phi-4-14B 83,3% файл 8,28 ГиБ
|
|
||||||
Qwen2.5-Coder-14B 75,0% файл 8,37 ГиБ
|
|
||||||
Qwen3-4B-2507 83,3% файл 2,33 ГиБ
|
|
||||||
Granite-4.2-8B 66,7% файл 5,16 ГиБ
|
|
||||||
```
|
|
||||||
|
|
||||||
**Расход видеопамяти у 14B при 64К не измерен ни разу.** В A45 их не было, столбец VRAM из A40 непригоден: он собрал занятость всей карты, а не процесса.
|
|
||||||
|
|
||||||
### Процессор годится для служебных ролей
|
|
||||||
|
|
||||||
Замер ревьюера, 32 потока из 72, AVX2:
|
|
||||||
|
|
||||||
```
|
|
||||||
LFM2.5-2.6B промпт 1150,8 ток/с генерация 13,4 ток/с
|
|
||||||
Qwen3-4B промпт 854,2 ток/с генерация 8,7 ток/с
|
|
||||||
```
|
|
||||||
|
|
||||||
Обработка промпта на процессоре быстрая, генерация медленная: она упирается в память и идёт последовательно.
|
|
||||||
|
|
||||||
### Сервер отдаёт всё нужное для честной работы
|
|
||||||
|
|
||||||
Проверено на живом `127.0.0.1:8081`:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /props → default_generation_settings.n_ctx = 196608, total_slots = 1
|
|
||||||
POST /tokenize → точное число токенов («def add(a, b): return a + b» = 10)
|
|
||||||
```
|
|
||||||
|
|
||||||
Предел контекста и размер задания **измеряются**, а не прикидываются.
|
|
||||||
|
|
||||||
### Чего в коде нет
|
|
||||||
|
|
||||||
```
|
|
||||||
разбиения задачи на части — нет
|
|
||||||
подсчёта токенов для планирования — нет; format_token_count только для показа
|
|
||||||
model_registry.context_window = 128000 — статическое умолчание,
|
|
||||||
а у модели владельца 196608
|
|
||||||
llama-swap — только ttl, групп нет: держать две модели резидентно не станет
|
|
||||||
```
|
|
||||||
|
|
||||||
### Поправка к постановке владельца
|
|
||||||
|
|
||||||
Оркестратор **не переставал** давать задачи локальной модели:
|
|
||||||
|
|
||||||
```
|
|
||||||
local_adapter.classify_error: "timeout", "timed out", "502", "503", "504"
|
|
||||||
→ ErrorCategory.TRANSIENT, retry_delay_seconds=2
|
|
||||||
```
|
|
||||||
|
|
||||||
Профиль не помечается исчерпанным и из цепочки не выбывает. Происходит другое: на каждом запросе локальная модель забирает отведённые Hermes 180 секунд, не успевает, и работу доделывает следующий в цепочке — платный. Чинить надо **подачу работы**, а не возврат в цепочку.
|
|
||||||
|
|
||||||
### Физика, которую нельзя обойти
|
|
||||||
|
|
||||||
**Две модели на одной V100 не работают вдвое быстрее.** Генерация упирается в пропускную способность памяти; две модели делят одну полосу. Вместе они выдадут примерно столько же, сколько одна.
|
|
||||||
|
|
||||||
Значит «параллельно» здесь означает **два независимых решения**, а не выигрыш во времени. Ускорение в интерфейсе обещать нельзя.
|
|
||||||
|
|
||||||
Оговорка: у MoE активны три миллиарда из тридцати, полосу они едят иначе. Два MoE могут ужиться лучше — **это гипотеза, её измеряют, а не закладывают**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 1. Замена моделей и замеры
|
|
||||||
|
|
||||||
## P0-1. Заменить кодер
|
|
||||||
|
|
||||||
Поставить `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M` вместо `Qwen3.8-27B`.
|
|
||||||
|
|
||||||
Основание измерено: **109,6 против 30,3 ток/с**, то же качество 83,3%, и на 4 ГиБ меньше.
|
|
||||||
|
|
||||||
1. Контекст **не ниже 65536** — порог отбора у Hermes.
|
|
||||||
2. Условия запуска взять у нынешнего юнита: `--flash-attn on`, `--cache-type-k/v q8_0`, `--reasoning off`, `--parallel 1`.
|
|
||||||
3. **Прежний юнит сохранить**; откат одной командой описать и проверить.
|
|
||||||
4. Скорость и видеопамять замерить **на живой службе**, а не переносить из A45.
|
|
||||||
|
|
||||||
## P0-2. Заменить компрессор
|
|
||||||
|
|
||||||
Поставить `LFM2.5-2.6B` вместо `Qwen3-4B` на порт 8082.
|
|
||||||
|
|
||||||
1. **Сначала замерить качество сжатия** на том же наборе, что у нынешнего. Быстрее — не значит лучше; сожмёт хуже, замену не делать и так и написать.
|
|
||||||
2. Рассмотреть запуск **на процессоре** (`-ngl 0`): освобождает видеопамять, а сжатие — чтение многого и запись малого, где процессор силён. Замерить оба варианта и дать владельцу числа для решения.
|
|
||||||
|
|
||||||
## P0-3. Измерить кандидатов в пару
|
|
||||||
|
|
||||||
Замерить **расход видеопамяти по процессу при 64К** для `Phi-4-14B`, `Qwen2.5-Coder-14B`, `Qwen3-4B-2507`, `Granite-4.2-8B` — через `nvidia-smi --query-compute-apps=pid,used_memory`, а не по занятости карты.
|
|
||||||
|
|
||||||
Затем **проверить запуском**, какие пары помещаются вместе с компрессором в 32 768 МиБ. Не расчётом.
|
|
||||||
|
|
||||||
Отдельно замерить, **что происходит со скоростью при одновременной работе двух моделей**: суммарная выработка против одиночной. Это проверка утверждения о полосе памяти, и её результат решает, имеет ли смысл держать пару резидентно.
|
|
||||||
|
|
||||||
**Часть 1 сдаётся отдельно.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 2. Надзиратель локальных моделей
|
|
||||||
|
|
||||||
Владелец: «если выбирается локальная модель, должен появляться субагент, который мониторит подачу работы. Если у модели не хватает контекста, он разбивает задачу на куски и подаёт, пока не заработает. Потом формирует память, какой объём давать модели».
|
|
||||||
|
|
||||||
## P0-4. Роль надзирателя
|
|
||||||
|
|
||||||
1. **Новая каноническая роль** `local-supervisor` в реестре.
|
|
||||||
2. **Включается автоматически**, когда выбранный профиль локальный (`local`, `llama.cpp`, `ollama`, `vllm`). Вручную назначать не нужно.
|
|
||||||
3. **Не встаёт между ролью и платным провайдером.**
|
|
||||||
4. **Надзиратель — не модель, а распорядитель.** Считает, режет, подаёт, наблюдает; работу делает локальная модель. Тратить на него платный вызов нельзя.
|
|
||||||
|
|
||||||
Основная его работа — счёт и разбор, а не рассуждение: токены считает `/tokenize`, предел даёт `/props`, границы кусков определяются разбором кода. Ставить сюда слабую модель значит сделать надзирателя менее надёжным.
|
|
||||||
|
|
||||||
## P0-5. Замер перед подачей, а не догадка
|
|
||||||
|
|
||||||
1. **Предел контекста брать у живого сервера** через `/props`. Умолчание `model_registry` = 128000 к модели владельца отношения не имеет.
|
|
||||||
2. **Размер задания считать через `/tokenize`** — точно. Оценка по символам допустима только запасным путём и должна быть помечена как оценка.
|
|
||||||
3. **Учитывать место под ответ**: в контекст входят задание, история и ожидаемый ответ. Запас обосновать.
|
|
||||||
4. **Не выдумывать пределы.** Сервер не ответил — так и записать.
|
|
||||||
|
|
||||||
## P0-6. Разбиение и подача
|
|
||||||
|
|
||||||
1. **Помещается — подавать целиком.** Резать без нужды вредно: теряется связность.
|
|
||||||
2. **Не помещается — резать по смысловым границам**: файл, функция, класс, раздел. Посреди выражения — нельзя.
|
|
||||||
3. **Подавать последовательно**, передавая накопленный результат, и собирать ответ.
|
|
||||||
4. **Неделимая задача — честный отказ**, а не разрез наугад.
|
|
||||||
5. **Число попыток ограничено** и настраивается. Бесконечный цикл на единственной видеокарте недопустим.
|
|
||||||
6. **После исчерпания попыток** — отказ с причиной, дальше обычная отказоустойчивость. Надзиратель **не прячет неудачу**, удерживая работу на локальной модели любой ценой.
|
|
||||||
|
|
||||||
## P0-7. Наблюдение за ходом
|
|
||||||
|
|
||||||
1. **Видеть, что модель работает, а не висит**: поток ответа или тайминги сервера.
|
|
||||||
2. **Различать три исхода**: успел, не успел, ответил ошибкой. Сейчас всё сваливается в «таймаут».
|
|
||||||
3. **Показывать ход** на «Обзоре»: какой кусок из скольких, сколько токенов подано.
|
|
||||||
4. **Отдельно ловить случай A39**: весь лимит ушёл на рассуждения, ответа нет. Измерено: 1500 токенов за 111 секунд и **ноль символов ответа**; с `enable_thinking: false` — ответ за 11 секунд. Признак — пустой ответ при полном расходе лимита; лечится `request_options`, механизм есть после A39.
|
|
||||||
|
|
||||||
## P0-8. Память: какой объём модель тянет
|
|
||||||
|
|
||||||
1. **Записывать по каждой модели**: при каком размере получался ответ, при каком нет, сколько занимало, какой кусок оказался рабочим.
|
|
||||||
2. **Хранить в общей памяти** (`/srv/projects/AI-Memory`, структура после A47). Запись подписывается: модель, когда, по какому заданию.
|
|
||||||
3. **Использовать при следующей подаче**: начинать с размера, который уже работал.
|
|
||||||
4. **Привязывать к имени сборки из метаданных GGUF**, а не к порту или имени профиля. Урок Tiel-Coder: в файле оказалась `Ornith-1.5-35B`.
|
|
||||||
5. **Показывать во вкладке «Память»** надзирателя, что он усвоил.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 3. Пара кодеров и облачный судья
|
|
||||||
|
|
||||||
Планировать **по числам части 1**.
|
|
||||||
|
|
||||||
## P0-9. Схема и её цена
|
|
||||||
|
|
||||||
```
|
|
||||||
задание → Кодер A (локальный) ┐
|
|
||||||
→ Кодер B (локальный) ┘→ судья (облачная модель)
|
|
||||||
├ принято → дальше по конвейеру
|
|
||||||
└ не принято → обоим на доработку
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **Кодеры не видят работу друг друга** до суда. Иначе второе решение не независимо и смысл теряется.
|
|
||||||
2. **Судья облачный**, видеопамяти не занимает. Это роль `developer-2` существующего конвейера; модель задаёт владелец в интерфейсе — **зашивать имя модели или провайдера в код нельзя**.
|
|
||||||
3. **Судья получает задание и оба решения**, возвращает: какое принято либо что доработать каждому.
|
|
||||||
4. **Ревьюер остаётся на своём месте** после судьи; конвейер не переделывать.
|
|
||||||
5. **Пара включается настройкой**; возврат к одному кодеру возможен.
|
|
||||||
|
|
||||||
**Предел итераций обязателен.** Круг «пока не сделают правильно» тратит платную квоту судьи на каждом обороте.
|
|
||||||
|
|
||||||
6. **Предел кругов** настраивается, умолчание обосновать. Механизм ограничения итераций в конвейере уже есть — использовать его.
|
|
||||||
7. **Показывать номер круга.**
|
|
||||||
8. **Круги исчерпаны — честный отказ** с последним состоянием обеих работ и мнением судьи. Частичный результат за готовый не выдавать.
|
|
||||||
9. **Считать расход**: сколько вызовов судьи ушло на задачу.
|
|
||||||
10. **Круг без изменений — застревание.** Оба вернули то же, что и в прошлый раз — прекратить и сказать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-10. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Числа части 1 сняты на живой службе**, а не перенесены из A45.
|
|
||||||
2. **Откат к прежнему кодеру** выполнен и проверен.
|
|
||||||
3. **Пара проверена запуском**, а не расчётом: обе модели подняты, памяти хватило, обе отвечают.
|
|
||||||
4. **Утверждение о полосе памяти** проверено: суммарная выработка двух моделей против одиночной. Результат записать, каким бы он ни был.
|
|
||||||
5. **Заведомо большая задача** разбита, подана и собрана; **неделимая** дала честный отказ.
|
|
||||||
6. **Предел попыток и предел кругов** проверены задачей, которая не выполнится никогда.
|
|
||||||
7. **Предел контекста взят у сервера**, счёт токенов сверен с `/tokenize` независимо.
|
|
||||||
8. **Независимость кодеров**: решение одного не попадает в контекст другого.
|
|
||||||
9. **Модель судьи задаётся из интерфейса**, а не зашита.
|
|
||||||
10. **Для платных провайдеров путь не изменился**, надзиратель туда не лезет.
|
|
||||||
11. **Службы владельца вернуть в рабочее состояние.**
|
|
||||||
12. **Побочные изменения** объяснить.
|
|
||||||
13. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Видеокарта одна, сервер рабочий: окна для замеров согласовать, службы возвращать в строй.
|
|
||||||
- Прежние юниты сохранять, откат описывать и проверять.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Имена моделей и провайдеров в код не зашивать.
|
|
||||||
- Конфигурацию Hermes не править.
|
|
||||||
- Версию `0.1.1` не поднимать.
|
|
||||||
- Правило честности без исключений: ни одного числа без замера; ускорение не обещать без подтверждения.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
**Часть 1**
|
|
||||||
|
|
||||||
1. Кодер заменён на Qwen3-Coder-30B-A3B, контекст не ниже 65536; скорость и видеопамять замерены на живой службе; откат проверен.
|
|
||||||
2. Компрессор замерен в обоих вариантах; замена сделана либо обоснованно отклонена.
|
|
||||||
3. Видеопамять четырёх кандидатов при 64К измерена по процессу.
|
|
||||||
4. Проверено запуском, какие пары помещаются; измерено, что со скоростью при одновременной работе.
|
|
||||||
|
|
||||||
**Часть 2**
|
|
||||||
|
|
||||||
5. Роль `local-supervisor` включается автоматически для локальных профилей; для остальных путь не изменился.
|
|
||||||
6. Предел контекста берётся через `/props`; размер задания считается через `/tokenize`; проверено.
|
|
||||||
7. Большая задача разбивается по смысловым границам и собирается; неделимая даёт отказ.
|
|
||||||
8. Число попыток ограничено; бесконечного цикла нет.
|
|
||||||
9. Ход виден владельцу; случай «весь лимит на рассуждения» распознаётся отдельно от таймаута.
|
|
||||||
10. Рабочий объём записан в общую память с привязкой к имени сборки GGUF и используется при следующей подаче.
|
|
||||||
|
|
||||||
**Часть 3**
|
|
||||||
|
|
||||||
11. Пара работает независимо; облачный судья сравнивает и возвращает на доработку.
|
|
||||||
12. Модель судьи задаётся из интерфейса.
|
|
||||||
13. Предел кругов работает; застревание распознаётся; расход вызовов судьи показан.
|
|
||||||
14. Пара включается и отключается настройкой.
|
|
||||||
|
|
||||||
**Общее**
|
|
||||||
|
|
||||||
15. Неизмеренное показано как `Н/Д` с причиной; неудачи не скрываются.
|
|
||||||
16. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
|
||||||
17. Службы владельца работают.
|
|
||||||
18. Память проекта в AI-Memory обновлена.
|
|
||||||
19. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Сейчас локальная модель получает задачу целиком, не успевает за отведённое время и отдаёт работу платному провайдеру. Так на каждом запросе: она не выбывает из цепочки, она просто всякий раз проигрывает.
|
|
||||||
|
|
||||||
Замена кодера окупается сама по себе — вчетверо быстрее при том же качестве и на четыре гигабайта меньше. Надзиратель делает подачу работы соразмерной модели: измеряет, а не предполагает, режет по смыслу и запоминает рабочий объём. Пара кодеров с облачным судьёй добавляет вторую независимую попытку — но её ценность в разных ошибках, а не в скорости, и каждый круг доработки стоит платного вызова.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
# Задание A55: оставшиеся дефекты подключения аккаунтов
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-08-31
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`26f7d2c`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a55-account-connection origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
|
||||||
|
|
||||||
Владелец не может настроить ни одного аккаунта. Это блокирует всю работу с хабом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что ревьюер уже починил — не переделывать
|
|
||||||
|
|
||||||
В `main` закрыто и проверено исполнением:
|
|
||||||
|
|
||||||
```
|
|
||||||
кэш опознания _identities/_snapshots не чистились при удалении ключа:
|
|
||||||
слот, переиспользованный под другой аккаунт, показывал
|
|
||||||
прежнюю почту. Добавлен forget_profile.
|
|
||||||
NVIDIA успешный список моделей теперь считается доказательством
|
|
||||||
рабочего ключа; отказ пробного запроса («Function ... Not
|
|
||||||
found for account») больше не валит подключение.
|
|
||||||
401 и 403 по-прежнему отказ.
|
|
||||||
выход из программы неудачная остановка процессов больше не отменяет выход
|
|
||||||
конфигурация Hermes проверяется семь известных путей, в сообщении
|
|
||||||
перечисляется, где искали
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Antigravity не подключается на Windows
|
|
||||||
|
|
||||||
Владелец: «на винде не подключается аккаунт аги, выдаёт ошибку API, хотя в браузере вышло, что авторизация прошла. Скорее всего требует ссылку с браузера, как в линуксе».
|
|
||||||
|
|
||||||
В браузере открывается `127.0.0.1:<порт>` и показывается «Авторизация успешно завершена», но мастер этого не видит и завершает шаг 3 с «Не указан API-ключ или не завершена авторизация».
|
|
||||||
|
|
||||||
1. **Разобраться, почему успешный возврат не доходит до мастера** на Windows, тогда как на Linux доходит.
|
|
||||||
2. **Мастер обязан дождаться** завершения входа и увидеть его результат, а не требовать ключ у провайдера, который работает по ссылке.
|
|
||||||
3. **Если возврат по ссылке на Windows невозможен** — дать тот же путь, что на Linux: поле для вставки ссылки или кода. Владелец сам это предположил.
|
|
||||||
4. **Сообщение «Не указан API-ключ» для Antigravity неверно по сути**: у него ключа нет, у него вход по ссылке. Текст должен соответствовать способу подключения.
|
|
||||||
|
|
||||||
## P0-2. Ollama ищет сервер не там
|
|
||||||
|
|
||||||
Ошибка `WinError 10061` честна, но бесполезна: мастер по умолчанию подставляет `http://127.0.0.1:11434/v1`, то есть машину, где запущен хаб. Ollama владельца работает **на сервере**.
|
|
||||||
|
|
||||||
1. **Подсказка должна объяснять**, что адрес относится к машине с хабом, и предлагать указать сетевой адрес сервера.
|
|
||||||
2. **Кнопка «Найти на этом компьютере»** уже есть — добавить проверку заданного вручную адреса с внятным ответом.
|
|
||||||
3. **Суффикс `/v1` для Ollama лишний**: нативный интерфейс живёт на `/api`. Проверить, какой адрес подставляется по умолчанию и куда потом идут запросы.
|
|
||||||
|
|
||||||
## P0-3. Antigravity на Linux: подключился, моделей нет
|
|
||||||
|
|
||||||
Аккаунт подключён, но «Список моделей ещё не получен», «Каталог моделей (0)», состояние «Не проверялось». При этом квоты подтянулись и показывают 100% — значит связь с провайдером есть.
|
|
||||||
|
|
||||||
1. **Выяснить, почему квоты приходят, а список моделей нет.** Источники разные, и один работает.
|
|
||||||
2. Возможно, поможет уже сделанный сброс кэша — **проверить на живой установке владельца** до того, как чинить что-то ещё.
|
|
||||||
|
|
||||||
## P0-4. Версия в интерфейсе
|
|
||||||
|
|
||||||
После правки ревьюера номер версии берётся из API, а зашитые значения из разметки убраны. На сборке `b2ca7cd` владелец всё ещё видит `Hermes Hub Web v0.1.1` при версии `0.1.2`.
|
|
||||||
|
|
||||||
1. **Проверить, что API отдаёт версию** и что клиент её получает на всех экранах, а не только при открытии панели обновления.
|
|
||||||
2. **Не подставлять значение по умолчанию.** Нет версии — писать `Н/Д` с причиной.
|
|
||||||
|
|
||||||
## P0-5. Экран настроек пуст
|
|
||||||
|
|
||||||
На «Настройках» половина полей не заполнена: «Н/Д: нет в снапшоте», «Н/Д: API не передаёт путь», «Н/Д: текущее значение не передано». Пустуют хост и порт, токен, порог квоты, маскирование почты, каталоги данных и конфигурации, путь к журналу.
|
|
||||||
|
|
||||||
1. **Передавать текущие значения настроек** в снапшот, чтобы поля показывали настроенное, а не заглушку.
|
|
||||||
2. **Токен не показывать целиком** — достаточно признака «задан» и возможности заменить.
|
|
||||||
3. **Значение действительно неизвестно — оставить `Н/Д` с причиной.** Заполнять правдоподобным нельзя.
|
|
||||||
|
|
||||||
## P0-6. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Пройти путь подключения целиком на обеих машинах**: Antigravity, NVIDIA, OpenRouter, Ollama. Скриншоты приложить.
|
|
||||||
2. **Проверить, что после смены аккаунта в слоте показывается новая почта** — правка ревьюера, убедиться, что она работает на живой установке.
|
|
||||||
3. **Проверить сообщение о конфигурации Hermes**: оно должно перечислять проверенные пути.
|
|
||||||
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
|
|
||||||
5. **Побочные изменения** объяснить.
|
|
||||||
6. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Правки ревьюера из `main` не откатывать.
|
|
||||||
- Версию `0.1.2` не понижать.
|
|
||||||
- Правило честности без исключений: причина отказа доходит до владельца текстом, неизвестное показывается как `Н/Д` с причиной.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Antigravity подключается на Windows; путь входа проверен вручную, скриншоты приложены.
|
|
||||||
3. Текст ошибки соответствует способу подключения провайдера.
|
|
||||||
4. Ollama: подсказка объясняет, чья это машина; заданный вручную адрес проверяется; суффикс пути верный.
|
|
||||||
5. Antigravity на Linux отдаёт список моделей; причина прежнего отказа названа.
|
|
||||||
6. Версия в интерфейсе совпадает с установленной на всех экранах.
|
|
||||||
7. Поля настроек показывают текущие значения; неизвестное помечено `Н/Д` с причиной.
|
|
||||||
8. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
|
|
||||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Владелец третий день не может подключить ни одного аккаунта. Часть причин уже устранена — подменённая почта из кэша, ложный отказ NVIDIA, отмена выхода из программы. Осталось четыре: вход Antigravity на Windows, адрес Ollama, отсутствие моделей на Linux и незаполненные настройки.
|
|
||||||
|
|
||||||
Каждая проверяется вручную на живой установке. Тесты все эти дефекты пропустили.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
# Задание A56: сжатие контекста — компрессор должен начать работать
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-09-01
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`26f7d2c`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a56-context-compression origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
|
||||||
|
|
||||||
Зона: надзиратель локальных моделей и локальный адаптер. С A55 (подключение аккаунтов) не пересекается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
На сервере владельца работает вторая локальная модель, называемая компрессором. Ревьюер проверил код: **в хабе нет ни одной строки, которая бы к ней обращалась для сжатия**. Порт 8082 упоминается ровно один раз — в списке адресов для обнаружения локальных серверов.
|
|
||||||
|
|
||||||
Надзиратель из A52 умеет резать задачу на куски по смысловым границам, и это работает. Но накопленный контекст между кусками никто не сжимает, и модель простаивает.
|
|
||||||
|
|
||||||
## Что проверено ревьюером на живом сервере — заново не мерить
|
|
||||||
|
|
||||||
Настройка после переделки раскладки:
|
|
||||||
|
|
||||||
```
|
|
||||||
кодер Qwen3-Coder-30B-A3B порт 8081 -c 229376 30 008 МиБ 107,4 ток/с
|
|
||||||
компрессор Qwen3-4B-2507 порт 8082 -c 32768 на CPU, -ngl 0 -t 32
|
|
||||||
604 МиБ видеопамяти
|
|
||||||
свободно на карте: 2 152 МиБ из 32 768
|
|
||||||
```
|
|
||||||
|
|
||||||
**Скорость компрессора на процессоре измерена на настоящем промпте:**
|
|
||||||
|
|
||||||
```
|
|
||||||
промпт 5068 токенов → 853,9 ток/с
|
|
||||||
генерация → 5,4 ток/с
|
|
||||||
```
|
|
||||||
|
|
||||||
То есть сжать 32 тысячи токенов — около 38 секунд чтения плюс несколько секунд на сводку. Чтение быстрое, генерация медленная; для сжатия это удачное сочетание, потому что на выходе короткий текст.
|
|
||||||
|
|
||||||
**Качество сжатия у этой модели замерено в A52: 100%** — сохраняет порты, адреса, контрольные суммы. Проверено ревьюером повторно: в ответе остались и адрес сервера, и оба порта, и имя модели.
|
|
||||||
|
|
||||||
**Родной контекст кодера — 262 144** по метаданным GGUF, поэтому 224К внутри предела.
|
|
||||||
|
|
||||||
**Обёртка над llama-server удалена**, юниты описывают действительность. Подменять бинарник больше нельзя: настройки задаются юнитом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Компрессор становится настраиваемой ролью
|
|
||||||
|
|
||||||
1. **Отдельная роль или настройка профиля** — «модель для сжатия контекста». Владелец выбирает её в интерфейсе из подключённых локальных профилей.
|
|
||||||
2. **Адрес берётся из профиля**, а не зашивается. Порт 8082 сегодняшний, завтра другой.
|
|
||||||
3. **Компрессор не участвует в маршрутизации Hermes.** Это служебная роль: она не должна попадать в цепочки ролей и не обязана проходить порог в 64К. Если владелец захочет — назначит её явно, но по умолчанию нет.
|
|
||||||
4. **Компрессор не настроен — сжатие не выполняется**, и это нормальное состояние. Показывать `Н/Д: модель для сжатия не выбрана`, а не ошибку.
|
|
||||||
|
|
||||||
## P0-2. Когда сжимать
|
|
||||||
|
|
||||||
1. **Порог по заполнению контекста**, а не по числу сообщений. Предел берётся у сервера через `/props`, размер накопленного — через `/tokenize`; оба механизма уже есть в надзирателе после A52.
|
|
||||||
2. **Значение порога настраивается**, умолчание обосновать. Разумно начинать сжатие, когда занято около трёх четвертей.
|
|
||||||
3. **Сжимать самое старое**, оставляя свежее нетронутым: последние сообщения нужны модели дословно.
|
|
||||||
4. **Не сжимать то, что уже сжато.** Повторное сжатие сводки теряет факты и делает это незаметно.
|
|
||||||
|
|
||||||
## P0-3. Что сохранять обязательно
|
|
||||||
|
|
||||||
Главное требование к качеству, и оно проверяемое.
|
|
||||||
|
|
||||||
Сводка обязана сохранять **дословно**: пути к файлам, адреса и порты, имена функций и переменных, контрольные суммы, номера версий и коммитов, точные значения из замеров.
|
|
||||||
|
|
||||||
1. **Проверять это тестом**: подать текст с известными значениями и убедиться, что они в сводке остались.
|
|
||||||
2. **Потеря факта — дефект**, а не приемлемая цена сжатия. Модель на этой задаче даёт 100%, значит планка достижима.
|
|
||||||
3. **Указывать степень сжатия**: было столько токенов, стало столько.
|
|
||||||
|
|
||||||
## P0-4. Видно, что происходит
|
|
||||||
|
|
||||||
1. **Показывать факт сжатия** владельцу: когда, сколько токенов было и стало, сколько заняло.
|
|
||||||
2. **Хранить исходный текст** до конца задачи, чтобы можно было вернуться, если сводка потеряла нужное.
|
|
||||||
3. **Сжатие не должно идти молча**: 38 секунд тишины владелец воспримет как зависание.
|
|
||||||
4. **Ошибка сжатия не роняет задачу.** Компрессор не ответил — работаем с несжатым контекстом и говорим об этом, а не прекращаем работу.
|
|
||||||
|
|
||||||
## P0-5. Память о том, что сработало
|
|
||||||
|
|
||||||
Продолжение линии A52.
|
|
||||||
|
|
||||||
1. Записывать в общую память (`/srv/projects/AI-Memory`): какой объём сжимался, во сколько раз, сколько заняло, сохранились ли факты.
|
|
||||||
2. Привязывать к **имени сборки GGUF**, а не к порту или имени профиля.
|
|
||||||
3. Использовать накопленное: начинать с размера куска, который уже давал хороший результат.
|
|
||||||
|
|
||||||
## P0-6. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Проверить на живом сервере**, а не заглушкой: подать текст больше порога и убедиться, что сжатие произошло и факты уцелели.
|
|
||||||
2. **Проверить сохранение дословных значений** — пути, порты, суммы. Это главный критерий.
|
|
||||||
3. **Проверить, что компрессор не попал в маршрутизацию** Hermes и не мешает выбору моделей.
|
|
||||||
4. **Проверить поведение при недоступном компрессоре**: задача продолжается на несжатом контексте.
|
|
||||||
5. **Убедиться, что предел контекста и счёт токенов берутся у сервера**, а не из умолчаний.
|
|
||||||
6. **Побочные изменения** объяснить.
|
|
||||||
7. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Юниты владельца не править: обёртку над `llama-server` только что убрали, подменять бинарник запрещено.
|
|
||||||
- Службы `qwen-coder` и `qwen-compressor` возвращать в рабочее состояние после проверок.
|
|
||||||
- Адреса и порты в код не зашивать.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Версию `0.1.2` не понижать.
|
|
||||||
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной, потерянный факт — дефект.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Модель для сжатия выбирается в интерфейсе; адрес берётся из профиля.
|
|
||||||
3. Компрессор не участвует в маршрутизации Hermes по умолчанию.
|
|
||||||
4. Порог сжатия считается от предела контекста, взятого через `/props`, и объёма, посчитанного через `/tokenize`.
|
|
||||||
5. Сжимается старое, свежее остаётся дословным; повторное сжатие сводки не выполняется.
|
|
||||||
6. Тест на сохранение дословных значений проходит: пути, порты, контрольные суммы, номера версий.
|
|
||||||
7. Владелец видит факт и степень сжатия; исходный текст сохраняется до конца задачи.
|
|
||||||
8. Недоступный компрессор не роняет задачу.
|
|
||||||
9. Опыт записан в общую память с привязкой к имени сборки GGUF.
|
|
||||||
10. Проверено на живом сервере, вывод приложен.
|
|
||||||
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
|
|
||||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Владелец держит на сервере вторую модель под сжатие контекста, освободил ради неё место и вынес её на процессор. Модель работает, отвечает и сжимает правильно — но в хабе нет кода, который бы её позвал.
|
|
||||||
|
|
||||||
Задание закрывает разрыв между настроенным железом и неиспользуемой возможностью. Ключевое требование одно: **сводка не теряет фактов**. Модель на этой задаче даёт сто процентов, значит планка достижима, и снижать её нельзя — потерянный путь или порт всплывёт через два шага в виде необъяснимой ошибки.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
# Задание A59: обновление, которое видно и доводит себя до конца
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-09-02
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`58cb88e`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a59-visible-update origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
|
|
||||||
|
|
||||||
Зона: обновление и перезапуск. С A58 (состояние `agy`) не пересекается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец показал, как это устроено в Cockpit Tools, и хочет так же:
|
|
||||||
|
|
||||||
> захожу в программу и независимо от того, работала она или нет, выходит окно об
|
|
||||||
> обновлении. ставишь новую версию (что на линукс, что на винде) — сразу видно
|
|
||||||
> загрузку и что скачивается. потом он останавливает сам все службы,
|
|
||||||
> устанавливает программу новую и запускает.
|
|
||||||
|
|
||||||
Сейчас владелец ставит каждую сборку установщиком вручную. Обновление внутри программы написано, но не доведено до вида, в котором им пользуются.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что проверено ревьюером — заново не выяснять
|
|
||||||
|
|
||||||
Строить с нуля ничего не надо, почти всё уже есть.
|
|
||||||
|
|
||||||
```
|
|
||||||
UpdateManager.check_for_updates есть
|
|
||||||
UpdateManager._download_file есть, с обработчиком хода: content-length и
|
|
||||||
progress_cb(downloaded / total)
|
|
||||||
UpdateManager.download_and_verify есть, с проверкой SHA-256
|
|
||||||
UpdateManager.install_latest_update есть
|
|
||||||
UpdateManager.schedule_restart есть
|
|
||||||
действия check_updates и apply_update есть в обработчике
|
|
||||||
проверка при запуске сервера есть: server.py вызывает check_for_updates
|
|
||||||
проверка при открытии интерфейса есть: app.js вызывает checkUpdates(true)
|
|
||||||
```
|
|
||||||
|
|
||||||
Три разрыва, и все на виду.
|
|
||||||
|
|
||||||
1. **Проверка при запуске молчит.** `checkUpdates(true)` — тихий режим: обновление находится, но владельцу не показывается ничего.
|
|
||||||
2. **Ход скачивания никуда не идёт.** `progress_cb` в загрузчике есть, но его никто не передаёт и результат не отображается.
|
|
||||||
3. **Останов служб и запуск после установки** держится на `schedule_restart` и не проверен на обеих системах.
|
|
||||||
|
|
||||||
Плюс внешнее условие: **лента релизов отстала**. Последний опубликованный — `v0.1.2-b7`, а установлена `0.1.3`. Пока свежий релиз не опубликован, обновлять не на что, и проверить работу нельзя.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Окно при запуске
|
|
||||||
|
|
||||||
1. **Обновление есть — показывается окно**, а не значок в углу. Независимо от того, работала программа до этого или нет.
|
|
||||||
2. **В окне: номер версии, что нового, размер загрузки.** «Что нового» брать из описания релиза; нет описания — писать `Н/Д: описание не приложено`, а не пустоту.
|
|
||||||
3. **Отказаться можно**, и отказ запоминается для этой версии: повторно то же окно не всплывает.
|
|
||||||
4. **Обновления нет — окна нет.** Молчание при отсутствии новостей.
|
|
||||||
|
|
||||||
## P0-2. Скачивание видно
|
|
||||||
|
|
||||||
1. **Полоса хода и проценты**, источник — `progress_cb`, он уже написан.
|
|
||||||
2. **Показывать, что именно скачивается**: имя файла и размер, «сколько из скольких».
|
|
||||||
3. **Нет `content-length` — так и писать**: `Н/Д: сервер не сообщил размер`, и показывать скачанный объём без процентов. Выдумывать проценты нельзя.
|
|
||||||
4. **Скачивание можно отменить**, недокачанный файл удаляется.
|
|
||||||
5. **Проверка SHA-256 остаётся обязательной.** Не сошлась — установка не начинается, файл удаляется, причина на экран.
|
|
||||||
|
|
||||||
## P0-3. Установка доводит себя до конца
|
|
||||||
|
|
||||||
1. **Останавливаются все свои процессы**: веб-сервер, фоновые опросы, дочерние. Тот же порядок, что в установщике — там это уже сделано в `stop_running_hub`.
|
|
||||||
2. **Чужого не трогать.** Останавливать только своё: по признаку хаба, в своём пользователе.
|
|
||||||
3. **Установка и запуск** без участия владельца. После запуска — тот же экран, на котором он был.
|
|
||||||
4. **Работает на Linux и на Windows.** Разница только в способе запуска, поведение одинаковое.
|
|
||||||
5. **Сорвалось на середине — откат к прежней версии** и внятное сообщение. Программа обязана остаться работоспособной.
|
|
||||||
|
|
||||||
## P0-4. Видно, что обновилось
|
|
||||||
|
|
||||||
1. **После перезапуска показать, что версия сменилась**: было — стало.
|
|
||||||
2. **Строка сборки уже показывает коммит работающего процесса** (`running_commit`, снят при старте) и время запуска. Не ломать: это единственный признак, по которому отличают новый код от старого, пережившего установку.
|
|
||||||
3. **Событие в журнале** о применённом обновлении с обеими версиями.
|
|
||||||
|
|
||||||
## P0-5. Ничего лишнего
|
|
||||||
|
|
||||||
1. **Не опрашивать в цикле.** Проверка при запуске и по кнопке. Опрос в цикле уже приводил к тому, что интерфейс сам себя кормил запросами.
|
|
||||||
2. **Опросы состояния молчат** — они в `SILENT_ACTIONS`, туда же добавить опрос хода загрузки.
|
|
||||||
3. **Автоматическая установка без спроса запрещена.** Показать, предложить, дождаться нажатия.
|
|
||||||
|
|
||||||
## P0-6. Проверка исполнением
|
|
||||||
|
|
||||||
Тестами это не ловится: дело в живом переходе между версиями.
|
|
||||||
|
|
||||||
1. **Обновиться на живой установке** с предыдущей версии на текущую. На обеих системах. Скриншоты окна и полосы хода приложить.
|
|
||||||
2. **Замерить время** от нажатия до готовности.
|
|
||||||
3. **Проверить, что после перезапуска работает новый код** — по `running_commit`, а не по номеру версии.
|
|
||||||
4. **Проверить отказ**: испорченная сумма, обрыв сети, отмена на середине.
|
|
||||||
5. **Проверить, что старый процесс не остался.**
|
|
||||||
|
|
||||||
## P0-7. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Пройти обновление целиком на обеих системах.**
|
|
||||||
2. **Проверить, что проценты не выдумываются** при отсутствии `content-length`.
|
|
||||||
3. **Проверить откат** при сорвавшейся установке.
|
|
||||||
4. **Проверить, что останавливается только своё.**
|
|
||||||
5. **Побочные изменения** объяснить.
|
|
||||||
6. **Пропущенный пункт назвать пропущенным.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Проверку SHA-256 и список разрешённых адресов не ослаблять.
|
|
||||||
- Показ работающего коммита и времени запуска не ломать.
|
|
||||||
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
|
||||||
- Правки ревьюера из `main` не откатывать.
|
|
||||||
- Версию `0.1.3` не понижать.
|
|
||||||
- Правило честности без исключений: неизвестный размер — `Н/Д` с причиной, а не поддельные проценты.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. При запуске с доступным обновлением показывается окно; без обновления — не показывается.
|
|
||||||
3. Отказ от версии запоминается, окно не всплывает повторно.
|
|
||||||
4. Полоса хода и объём отображаются; без `content-length` — честное `Н/Д` без процентов.
|
|
||||||
5. Отмена удаляет недокачанный файл.
|
|
||||||
6. Несовпадение SHA-256 прекращает установку с сообщением.
|
|
||||||
7. Установка сама останавливает свои процессы, ставит и запускает; проверено на Linux и Windows.
|
|
||||||
8. Сорвавшаяся установка откатывается, программа остаётся работоспособной.
|
|
||||||
9. После перезапуска `running_commit` соответствует новой сборке.
|
|
||||||
10. Опроса в цикле нет; опрос хода загрузки молчалив.
|
|
||||||
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **708**.
|
|
||||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Механизм написан, но им нельзя пользоваться: проверка находит обновление и молчит, ход загрузки считается и никуда не выводится. Владелец из-за этого ставит каждую сборку руками, а сегодня их было десять.
|
|
||||||
|
|
||||||
Задание не про новый код, а про то, чтобы уже написанное дошло до экрана и довело себя до конца: показать, скачать на глазах, остановить своё, поставить, запустить.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
# Задание A60: обновление доводит себя до конца
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-09-02
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`2f35377`). A59 влит: ветка `antigravity/a59-visible-update`
|
|
||||||
принята с исправлениями ревьюера (`285ae7c`). Показ и загрузку переписывать
|
|
||||||
второй раз не надо.
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b antigravity/a60-update-completes origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
## Порядок исполнения
|
|
||||||
|
|
||||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан
|
|
||||||
для аудитора.
|
|
||||||
|
|
||||||
Зона: применение обновления и перезапуск. Показа и загрузки не касается — там всё
|
|
||||||
принято и проверено.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что уже сделано — переделывать не надо
|
|
||||||
|
|
||||||
A59 довёл обновление до экрана. Ревьюер проверил и принял:
|
|
||||||
|
|
||||||
```
|
|
||||||
окно при запуске, отказ по версии, молчание без обновлений работает
|
|
||||||
полоса хода, честное Н/Д без размера работает
|
|
||||||
отмена с удалением недокачанного файла работает
|
|
||||||
отмена принимается только на проверке и загрузке работает
|
|
||||||
SHA-256 обязательна, непроверенный файл не запускается работает
|
|
||||||
запись о применённом обновлении только после успеха работает
|
|
||||||
```
|
|
||||||
|
|
||||||
Ничего из этого не трогать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Владелец нажимает «Обновить сейчас». Пакет скачивается на глазах, сумма сходится,
|
|
||||||
начинается установка — и на этом всё кончается. Хаб не поднимается, окно гаснет,
|
|
||||||
владелец идёт ставить сборку руками. Ровно то, из-за чего писалось A59.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Разрыв, и он один на обеих системах
|
|
||||||
|
|
||||||
**Установщик снимает тот процесс, который его запустил и ждёт.**
|
|
||||||
|
|
||||||
`install_latest_update` делает так:
|
|
||||||
|
|
||||||
```
|
|
||||||
stop_running_hub() свои процессы, кроме текущего
|
|
||||||
subprocess.run(["bash", installer], timeout=600) ЖДЁТ здесь
|
|
||||||
schedule_restart() сюда управление не доходит
|
|
||||||
```
|
|
||||||
|
|
||||||
**Linux — проверено исполнением, цепочка целиком.**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. хаб: subprocess.run(["bash", installer], capture_output=True)
|
|
||||||
stdout установщика — труба, единственный читатель которой сам хаб
|
|
||||||
2. install-linux.sh, шаг [0/6]: stop_running_hub снимает хаб
|
|
||||||
pgrep -u $(id -u) -f "antigravity_provider.router.web|hermes_hub_web_entry"
|
|
||||||
никого не исключает, под шаблон попадает тот, кто запустил установщик
|
|
||||||
3. хаб мёртв -> у трубы не осталось читателя
|
|
||||||
4. следующий echo установщика -> SIGPIPE -> установщик умирает на шаге [1/6]
|
|
||||||
5. не установлено ничего; перезапускать нечего
|
|
||||||
```
|
|
||||||
|
|
||||||
**Установщик не доживает до конца — он умирает раньше, чем что-либо поставит.**
|
|
||||||
Это не «поставилось, но не запустилось»: манифест и код остаются на прежней
|
|
||||||
сборке. Проверено контрольным опытом — тот же скрипт, та же смерть родителя: с
|
|
||||||
выводом в файл доходит до конца, с `capture_output=True` умирает.
|
|
||||||
|
|
||||||
Отсюда следует, что одной перестановкой `schedule_restart` делу не помочь: пока
|
|
||||||
установщик пишет в трубу убитого им процесса, он не доживёт до установки при
|
|
||||||
любом порядке вызовов.
|
|
||||||
|
|
||||||
**Windows — прочитано по коду, живьём не проверялось.**
|
|
||||||
`installer/HermesHubSetup.cs:280` (`StopOwnedRuntime`) выглядит аккуратнее: строит `$protected` — цепочку собственных предков, чтобы не снять
|
|
||||||
того, кто его запустил. Но проверка `-notin $protected` стоит **только на
|
|
||||||
дочерних** процессах внутри `Stop-HubBranch`. Сам процесс-цель снимается
|
|
||||||
безусловно, а хаб под шаблон `antigravity_provider\.router\.web` подходит.
|
|
||||||
Труба там та же: `proc.wait(timeout=600)` при `Popen` без перенаправления вывода.
|
|
||||||
**Подтвердить исполнением, а не поверить на слово.**
|
|
||||||
|
|
||||||
Отката на путях установщика нет вообще: он есть только для `.zip`
|
|
||||||
(`apply_update_sync`). Ни `.sh`, ни `.exe` при срыве на середине ничего не
|
|
||||||
возвращают.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Отсоединённый помощник
|
|
||||||
|
|
||||||
Порядок не выдумывать заново — он описан в docstring `schedule_restart`: сначала
|
|
||||||
отсоединённый помощник, потом выход текущего процесса. Лаунчер считает хаб
|
|
||||||
работающим, пока порт отвечает, поэтому поднимать новый, не освободив порт,
|
|
||||||
бесполезно.
|
|
||||||
|
|
||||||
1. **Хаб порождает помощника** (`setsid` или отдельная группа процессов на Linux,
|
|
||||||
`DETACHED_PROCESS` на Windows), передаёт ему путь к уже проверенному пакету и
|
|
||||||
**выходит сам**, освободив порт.
|
|
||||||
2. **Помощник**: дожидается освобождения порта → запускает установщик → поднимает
|
|
||||||
хаб → завершается.
|
|
||||||
3. **Ни одна труба помощника не должна вести в хаб.** Это то самое место, где всё
|
|
||||||
ломается сейчас: `capture_output=True` делает читателем вывода тот процесс,
|
|
||||||
который установщик собирается снять. Вывод установщика — сразу в файл, а не в
|
|
||||||
`PIPE`, и не через процесс, которому предстоит умереть.
|
|
||||||
4. **Помощник не наследует** ни stdout хаба, ни его рабочий каталог: хаб исчезнет
|
|
||||||
раньше, чем помощник закончит.
|
|
||||||
5. **Помощник пишет свой ход в файл** `~/.hermes/updates/apply-<время>.log`, чтобы
|
|
||||||
после неудачи было что показать. Пустой отказ без причины — уже было в A59,
|
|
||||||
второй раз не проходит.
|
|
||||||
|
|
||||||
## P0-2. Владелец видит, что происходит
|
|
||||||
|
|
||||||
1. **Перед выходом статус `restarting`** с текстом, что хаб сейчас закроется и
|
|
||||||
поднимется сам. Не «установлено» — установка ещё идёт.
|
|
||||||
2. **Интерфейс переживает разрыв.** Опрос `get_update_progress` получит отказ
|
|
||||||
соединения: это ожидаемое состояние, а не ошибка. Показывать «Hermes Hub
|
|
||||||
перезапускается», продолжать пробовать, при возврате — перечитать страницу.
|
|
||||||
3. **Не молчать бесконечно.** Не поднялся за отведённое время — сказать это прямо
|
|
||||||
и назвать путь к журналу помощника.
|
|
||||||
|
|
||||||
## P0-3. Откат на путях установщика
|
|
||||||
|
|
||||||
1. **Помощник снимает копию установленного** до запуска установщика.
|
|
||||||
2. **Установщик вернул не ноль или хаб не поднялся** за отведённое время — вернуть
|
|
||||||
прежнее и поднять его.
|
|
||||||
3. **Причина отказа** — с кодом возврата и хвостом вывода — в журнал и на экран
|
|
||||||
при следующем старте.
|
|
||||||
4. **Программа обязана остаться работоспособной.** Это главное требование пункта:
|
|
||||||
неудачное обновление не имеет права оставить владельца без хаба.
|
|
||||||
|
|
||||||
## P0-4. Проверка исполнением
|
|
||||||
|
|
||||||
Тестами это не ловится: дело в живом переходе между версиями и в том, кто кого
|
|
||||||
снимает.
|
|
||||||
|
|
||||||
1. **Поставить `v0.1.2-b7`, обновиться на `v0.1.3-b1` через интерфейс.** Оба
|
|
||||||
релиза опубликованы, установщики и `checksums.txt` на месте. Скриншоты: окно,
|
|
||||||
полоса, экран после возврата.
|
|
||||||
2. **Замерить время** от нажатия до готовности.
|
|
||||||
3. **Проверить, что работает новый код** — по `running_commit`, снятому при старте
|
|
||||||
процесса, а не по номеру версии.
|
|
||||||
4. **Повторить на Windows.**
|
|
||||||
5. **Сорвать установку намеренно** (испорченный установщик) — проверить откат и
|
|
||||||
что хаб жив.
|
|
||||||
6. **Проверить, что старый процесс не остался** и порт занят новым.
|
|
||||||
|
|
||||||
## P0-5. Обновление вообще предлагается
|
|
||||||
|
|
||||||
Найдено тем же прогоном, до того как дело дошло до установки.
|
|
||||||
|
|
||||||
Хаб на `v0.1.2-b7` при живом релизе `v0.1.3-b1` ответил:
|
|
||||||
`«Установлена сборка новее опубликованного релиза (4fa9939 от 2026-09-02)»`,
|
|
||||||
`update_available: false`. Обновиться было нельзя вообще.
|
|
||||||
|
|
||||||
Причина: `deployed_at` в `deployment_manifest.json` пишется установщиком в момент
|
|
||||||
**установки**, а не сборки, и сравнивается с `published_at` релиза. Поставил
|
|
||||||
старую сборку сегодня — она «новее» любого релиза, и обновление не предложат
|
|
||||||
больше никогда. Владелец, переставивший сборку руками, выпадает из обновлений
|
|
||||||
молча.
|
|
||||||
|
|
||||||
1. **Сравнивать сборки, а не дату установки.** Дата установки не говорит о том,
|
|
||||||
какой код внутри.
|
|
||||||
2. **Если сравнить нечем — предложить обновление, а не промолчать.** Молчание
|
|
||||||
здесь дороже лишнего окна: владелец не узнает, что отстал.
|
|
||||||
3. **Причину решения показывать.** «Установлена сборка новее релиза» — вывод, а
|
|
||||||
не факт; рядом должно стоять, из чего он сделан.
|
|
||||||
|
|
||||||
## P0-6. Аудит вторым проходом
|
|
||||||
|
|
||||||
1. **Пройти обновление целиком на обеих системах.**
|
|
||||||
2. **Проверить, что помощник не снимает чужого** — только процессы хаба своего
|
|
||||||
пользователя.
|
|
||||||
3. **Проверить откат** при сорвавшейся установке и что после него хаб отвечает.
|
|
||||||
4. **Проверить, что интерфейс не объявляет успех раньше времени** — ни на выходе
|
|
||||||
хаба, ни на разрыве связи.
|
|
||||||
5. **Побочные изменения** объяснить.
|
|
||||||
6. **Пропущенный пункт назвать пропущенным.** В A59 живая проверка была пропущена
|
|
||||||
молча, при том что релиз для неё был опубликован за девять часов до сдачи.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Показ и загрузку из A59 не переделывать.
|
|
||||||
- Проверку SHA-256 и список разрешённых адресов не ослаблять.
|
|
||||||
- Показ работающего коммита и времени запуска не ломать.
|
|
||||||
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
|
||||||
- **Правки ревьюера из `main` не откатывать — включая комментарии.** В A59 сняли
|
|
||||||
шесть блоков с объяснением прошлых регрессий, ревьюер возвращал их руками.
|
|
||||||
- Версию `0.1.3` не понижать.
|
|
||||||
- Правило честности без исключений: неизвестное — `Н/Д` с причиной, а не
|
|
||||||
правдоподобное число и не полоса во всю ширину.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. Обновление, запущенное из интерфейса, доходит до работающего нового хаба **без
|
|
||||||
участия владельца** — на Linux и на Windows, подтверждено скриншотами.
|
|
||||||
3. После перезапуска `running_commit` соответствует новой сборке.
|
|
||||||
4. Сорвавшаяся установка откатывается, хаб остаётся работоспособным.
|
|
||||||
5. Старый процесс не остался, порт занят новым.
|
|
||||||
6. Журнал помощника пишется, и при отказе на него указывают.
|
|
||||||
7. Обновление предлагается по сравнению сборок, а не по дате установки;
|
|
||||||
переустановка старой сборки не выключает обновления навсегда.
|
|
||||||
8. `ruff check .` чисто; релизный гейт пройден; тестов не меньше **740**.
|
|
||||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
|
|
||||||
`X passed / Y skipped / Z failed`, тайминги, скриншоты.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
A59 сделал обновление видимым: владелец видит окно и видит загрузку. Дальше
|
|
||||||
механизм обрывается на самом простом — установщик снимает того, кто его запустил
|
|
||||||
и ждёт результата.
|
|
||||||
|
|
||||||
Задание про один шаг: чтобы после нажатия «Обновить сейчас» владелец больше
|
|
||||||
ничего не делал.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA`.
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
# Задание HUB-1: зелёный main и P0 из аудита Hermes Hub
|
|
||||||
|
|
||||||
## Для кого
|
|
||||||
**Серверная сессия Claude (пользователь `ochenstarik`), не для agy.** Это работа
|
|
||||||
исполнителя-Claude: правка кода, прогон, пуш. Ревьюер (сессия на ПК) принимает.
|
|
||||||
|
|
||||||
## Дата
|
|
||||||
2026-09-02.
|
|
||||||
|
|
||||||
## База
|
|
||||||
`origin/main` (`144f6a5`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b hub/audit-p0-green-main origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить. **Координация:** над `main` работают две сессии.
|
|
||||||
Перед пушем — `git fetch` и `git log --oneline origin/main`; при расхождении
|
|
||||||
перенести правки поверх, как это уже делалось.
|
|
||||||
|
|
||||||
## Зачем
|
|
||||||
|
|
||||||
По решению о слиянии (`docs/research/kagent-merge-decision.md`) шаг 1 —
|
|
||||||
**Hermes довести до зелёного и стабильного**, потому что он служит эталоном
|
|
||||||
переноса, а сломанный эталон портировать нельзя. Сейчас `main` красный. Полный
|
|
||||||
аудит — на рабочем столе владельца (`HERMES_HUB_FULL_AUDIT_2026-09-02.md`);
|
|
||||||
здесь только то, что подтверждено исполнением.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что ревьюер уже проверил — заново не выяснять
|
|
||||||
|
|
||||||
### CI на main красный. Причина — два дефекта, оба видны в логе последнего прогона
|
|
||||||
|
|
||||||
**1. Security-инвариант A37 не держится на Windows.**
|
|
||||||
`tests/test_a37_isolation_guards.py:394` падает:
|
|
||||||
|
|
||||||
```
|
|
||||||
AssertionError: команда со стильдой прошла мимо защиты: rm -rf $HOME/.hermes (OK)
|
|
||||||
assert not True
|
|
||||||
```
|
|
||||||
|
|
||||||
WorkspaceBoundaryGuard пропускает разрушительную команду с `$HOME`, потому что
|
|
||||||
раскрытие переменных и нормализация путей на Windows и Linux различаются. Это
|
|
||||||
не косметика — это граница вокруг агентских shell-действий. Пока она работает
|
|
||||||
по-разному на поддерживаемых системах, sandbox нельзя считать доказанным.
|
|
||||||
|
|
||||||
**2. Windows UTF-8 роняет verification-скрипт.**
|
|
||||||
`scripts/verify_multi_provider_router.py:63`:
|
|
||||||
|
|
||||||
```
|
|
||||||
UnicodeEncodeError: 'charmap' codec can't encode characters ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт печатает русский текст (`[PASS] Чистая конфигурация...`), а консоль
|
|
||||||
Windows в CI — cp1252. Падает `print`, не логика.
|
|
||||||
|
|
||||||
Красные джобы: `Headless Run (no GUI dependencies)` и
|
|
||||||
`Clean Windows Runner Test`.
|
|
||||||
|
|
||||||
### Баг pricing fallback (P2, но реальный)
|
|
||||||
|
|
||||||
`src/antigravity_provider/router/telemetry_service.py:164`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, dict) and "pricing" in data: # всегда False
|
|
||||||
```
|
|
||||||
|
|
||||||
`safe_dump` вместо `safe_load` — таблица цен из `pricing.yaml` не грузится
|
|
||||||
никогда, и `except: pass` это глушит. Должно быть `safe_load`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Зелёный main (это первично)
|
|
||||||
|
|
||||||
1. **Исправить UTF-8 в verification-скрипте**: принудительный UTF-8 вывода
|
|
||||||
(`PYTHONUTF8`, `PYTHONIOENCODING=utf-8`, реконфигурация `sys.stdout`, либо
|
|
||||||
безопасное кодирование). Кросс-платформенно, проверяемо на обеих системах.
|
|
||||||
2. **Исправить WorkspaceBoundaryGuard** единым конвейером: классификация
|
|
||||||
диалекта shell → раскрытие только распознанных переменных → нормализация
|
|
||||||
разделителей → разрешение `$HOME`/`%USERPROFILE%` → канонизация пути →
|
|
||||||
сравнение с защищёнными корнями → **fail closed**. Одинаковый тест-набор для
|
|
||||||
Windows и Linux; `test_a37_isolation_guards` должен ловить `rm -rf $HOME/...`
|
|
||||||
на обеих системах.
|
|
||||||
3. **Проверка — по зелёному CI**, а не локально: локальный прогон на Linux эти
|
|
||||||
две джобы не воспроизводит. Довести оба Windows-джоба до зелёного.
|
|
||||||
|
|
||||||
## P0-2. Остальные P0 аудита — подтвердить исполнением ПЕРЕД правкой
|
|
||||||
|
|
||||||
Ревьюер их не проверял. По каждому: сначала воспроизвести, потом чинить. Не
|
|
||||||
чинить со слов аудита.
|
|
||||||
|
|
||||||
1. **Release Gate заявляет проверку хеша, которой не было** — частичный HTTP
|
|
||||||
Range, но `PACKAGE_HASH_VERIFIED=True` без полного SHA-256. Прочитать
|
|
||||||
`scripts/release_gate.py`, подтвердить, затем считать полный хеш или брать
|
|
||||||
достоверный digest из release API.
|
|
||||||
2. **Publication gate fail-open** — 404/сеть/отсутствие пакета возвращаются как
|
|
||||||
PASS. Разделить Offline Gate (тесты, updater, статика, сборка) и Publication
|
|
||||||
Gate (релиз есть, ассеты есть, digest сверен, скачивание прошло).
|
|
||||||
3. **localhost `/api/action` без CSRF/Origin** — на loopback токен не требуется,
|
|
||||||
а действие меняет состояние. Проверить, затем: bootstrap-токен, проверка
|
|
||||||
`Origin`/`Sec-Fetch-Site`, авторизация небезопасных методов.
|
|
||||||
|
|
||||||
## P1. После зелёного main
|
|
||||||
|
|
||||||
1. **Zip-slip в updater**: распаковка обязана проверять каждый путь
|
|
||||||
(`resolved.is_relative_to(staging)`), запрет абсолютных путей, `..`, symlink,
|
|
||||||
device.
|
|
||||||
2. **pricing fallback**: `safe_load` вместо `safe_dump` (см. выше).
|
|
||||||
3. **CI-матрица Windows + Linux**: сейчас Linux-джоба нет, а проект на Linux и
|
|
||||||
активно получает Linux-фиксы.
|
|
||||||
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
|
|
||||||
extra, secret-scan шире) — отдельными заданиями, не в этом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- Правки ревьюера из `main` не откатывать.
|
|
||||||
- Фронтенд без npm/сборки/фреймворков — `docs/web-api/CONTRACT.md` §1.
|
|
||||||
- Проверку SHA-256 и список разрешённых адресов обновления не ослаблять.
|
|
||||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
|
||||||
- Версию `0.1.3` не понижать.
|
|
||||||
- Правило честности: неизмеренное — `Н/Д` с причиной.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. **Оба Windows-джоба CI зелёные** — ссылка на зелёный прогон в отчёте.
|
|
||||||
3. `test_a37_isolation_guards` ловит `rm -rf $HOME/...` на Windows и Linux;
|
|
||||||
guard fail-closed.
|
|
||||||
4. verification-скрипт не падает на cp1252.
|
|
||||||
5. Остальные P0 либо исправлены с доказательством, либо явно помечены как
|
|
||||||
отложенные с причиной.
|
|
||||||
6. `ruff check .` чисто; локальный прогон Linux зелёный; число тестов не меньше
|
|
||||||
текущего.
|
|
||||||
7. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
|
|
||||||
`X passed / Y skipped / Z failed`, ссылка на зелёный CI.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
Первично — зелёный main, и обе причины уже найдены: security-guard на Windows и
|
|
||||||
UTF-8 в verification. Остальные P0 аудита — только после подтверждения
|
|
||||||
исполнением. Это фундамент под слияние: пока Hermes красный и его sandbox-guard
|
|
||||||
дырявый на одной из систем, переносить его поведение в KAgent нельзя.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA` и ссылку на зелёный прогон CI.
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
# Задание A61: установщик и релизный конвейер — проверка на настоящей машине
|
|
||||||
|
|
||||||
## Для кого
|
|
||||||
|
|
||||||
**agy** (машина владельца, Windows, реальные учётные данные и `agy`). Не для
|
|
||||||
серверной сессии: у неё нет `csc.exe`, нет Windows-реестра, нет прав публиковать
|
|
||||||
релиз от имени владельца. Ревьюер (сессия на ПК) принимает.
|
|
||||||
|
|
||||||
## Дата поступления
|
|
||||||
2026-09-03
|
|
||||||
|
|
||||||
## База
|
|
||||||
|
|
||||||
`origin/main` (`89435ea`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git fetch origin --prune
|
|
||||||
git checkout -b installer/a61-live-verification origin/main
|
|
||||||
```
|
|
||||||
|
|
||||||
В `main` напрямую не пушить.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
HUB-1 довёл CI до зелёного на Windows и Linux и закрыл дыру в релизных
|
|
||||||
воротах: `release_gate.py` перестал заявлять проверку хеша, которой не было, и
|
|
||||||
перестал быть fail-open при обрыве сети или 404. Заодно нашлось — и осталось
|
|
||||||
непроверенным вживую, потому что для этого нужна настоящая Windows-машина, а
|
|
||||||
не CI-раннер:
|
|
||||||
|
|
||||||
Установщик — единственный способ, которым продукт попадает к владельцу, и он
|
|
||||||
**не проверяется нигде за пределами CI-раннера**, который сам его никогда не
|
|
||||||
собирает. Ни один прогон `pytest -m installer` не выполнялся на настоящей
|
|
||||||
установке. Ни один релиз ещё не прошёл через конвейер целиком — все прошлые
|
|
||||||
теги падали на `Release Gate Check` (см. `agents/done/2026-09-02-HUB1-audit-p0-green-main.md`,
|
|
||||||
раздел «Найдено сверх задания»), а действующие релизы на GitHub собраны и
|
|
||||||
выложены вручную, мимо `release.yml`.
|
|
||||||
|
|
||||||
Это задание не про код хаба — про то, что установщик и конвейер публикации
|
|
||||||
делают на реальной машине то же, что декларируют.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что уже проверено — заново не выяснять
|
|
||||||
|
|
||||||
### CI зелёный, но установщика не касается
|
|
||||||
|
|
||||||
`pyproject.toml`:
|
|
||||||
```
|
|
||||||
addopts = "-m 'not live and not network and not installer'"
|
|
||||||
```
|
|
||||||
Три теста в `tests/test_installer.py`, помеченные `@pytest.mark.installer`
|
|
||||||
(`test_setup_exe_exists`, `test_silent_installer_execution_with_hermes`,
|
|
||||||
`test_silent_installer_fails_without_hermes`), **исключены из каждого прогона**
|
|
||||||
по умолчанию, и ни в `.github/workflows/ci.yml`, ни в `release.yml` нет шага,
|
|
||||||
который передавал бы `-m installer` явно. К тому же все три сами пропускают
|
|
||||||
себя (`pytest.skip`), если `dist/HermesHubSetup.exe` не собран — а его никто
|
|
||||||
не собирает ни в CI, ни в конвейере релиза.
|
|
||||||
|
|
||||||
`tests/test_installer_windows_and_linux.py::test_windows_csharp_launchers_and_setup_compile`
|
|
||||||
пропускается в CI с `csc.exe compiler not found in standard .NET Framework
|
|
||||||
location` — компилятор ищется по путям `C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`
|
|
||||||
и `...\Framework\v4.0.30319\csc.exe`; на `windows-latest` раннере GitHub его
|
|
||||||
нет. На обычной Windows 10/11 он есть — так и написано в
|
|
||||||
`installer/README.md`: «compiles `HermesHubSetup.cs` using standard .NET
|
|
||||||
Framework `csc.exe` present on all Windows 10/11 machines without extra
|
|
||||||
toolchains».
|
|
||||||
|
|
||||||
### Тесты уже изолированы от твоего реестра
|
|
||||||
|
|
||||||
`test_silent_installer_execution_with_hermes` и
|
|
||||||
`test_silent_installer_fails_without_hermes` подставляют `HERMES_HOME`,
|
|
||||||
`LOCALAPPDATA`, `APPDATA`, `USERPROFILE` во временный каталог и ставят
|
|
||||||
`HERMES_HUB_NO_REGISTRY=1` — это отключает запись в `HKCU\...\Uninstall`
|
|
||||||
(закрыто ещё в A4, см. `agents/done/2026-08-21-A4-antigravity-credential-isolation.md`).
|
|
||||||
Прогон этих тестов не трогает твой реальный реестр и твою реальную установку.
|
|
||||||
`installer/README.md` отдельно требует того же: «Unit Tests: Must NEVER modify
|
|
||||||
user Windows Registry or Start Menu shortcuts» — этому требованию тесты уже
|
|
||||||
следуют, проверить нужно исполнением, а не читать код на слово.
|
|
||||||
|
|
||||||
### Релиз ещё никогда не публиковался этим конвейером
|
|
||||||
|
|
||||||
`gh run list --workflow=release.yml` на момент HUB-1 показывал failure на всех
|
|
||||||
пяти последних тегах, включая `v0.1.3-b1` — падение на `Run Release Gate
|
|
||||||
Check`, той же причине, что красила CI. HUB-1 эту причину устранил, но
|
|
||||||
**ни разу после починки конвейер не запускался** — значит и новые шаги
|
|
||||||
(`Built assets must be installable by the updater`,
|
|
||||||
`Publication Gate (published release must be verifiable)`, оба добавлены в
|
|
||||||
HUB-1) ни разу не выполнялись на настоящем прогоне GitHub Actions, только
|
|
||||||
локально функциями напрямую.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1. Собрать установщик и прогнать installer-тесты на настоящей машине
|
|
||||||
|
|
||||||
1. Собрать: `installer/build_installer.ps1` (компилирует `HermesHub.cs`,
|
|
||||||
`HermesHubWeb.cs`, `HermesHubSetup.cs` через `csc.exe`, кладёт
|
|
||||||
`dist/HermesHubSetup.exe`). Приложить вывод сборки.
|
|
||||||
2. Прогнать три `installer`-теста явно, отдельно от общего набора:
|
|
||||||
```
|
|
||||||
pytest -m installer tests/test_installer.py -v
|
|
||||||
```
|
|
||||||
Все три должны выполниться (не `SKIPPED`) и пройти. Приложить полный вывод.
|
|
||||||
3. Прогнать `test_windows_csharp_launchers_and_setup_compile` отдельно —
|
|
||||||
на твоей машине `csc.exe` должен найтись. Приложить вывод; если и здесь
|
|
||||||
`SKIPPED` — назвать точный путь, по которому компилятор искался и не
|
|
||||||
нашёлся, и где он есть на самом деле.
|
|
||||||
4. **Подтвердить исполнением, что реестр не тронут**: снять состояние
|
|
||||||
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesHub` до и
|
|
||||||
после прогона (`reg query`), приложить оба вывода. Совпадают — тесты не
|
|
||||||
соврали про изоляцию.
|
|
||||||
|
|
||||||
## P0-2. Полный цикл `/silent` на реальной установке
|
|
||||||
|
|
||||||
1. Установить через `dist/HermesHubSetup.exe /silent` в **реальный**
|
|
||||||
(не временный) профиль — как ставит владелец.
|
|
||||||
2. Проверить коды возврата по правилу из `agents/AGENTS.md` §4: `0`, `10`,
|
|
||||||
`11`, `12` — на тех сценариях, для которых они определены (обычная
|
|
||||||
установка, установка без Hermes Agent, повторная установка, откат).
|
|
||||||
Каждый код — с описанием сценария, который его вызвал.
|
|
||||||
3. После установки — обычный рабочий цикл: хаб запускается, видит существующие
|
|
||||||
профили `agy`, ничего не потеряно. Если что-то потерялось — это находка, а
|
|
||||||
не повод откатывать проверку молча.
|
|
||||||
4. **Не удалять существующие профили и учётные данные для эксперимента.**
|
|
||||||
Если для чистоты нужна отдельная установка — использовать переменные
|
|
||||||
изоляции (`HERMES_HOME` и т.д.), как это уже делают тесты, а не боевой
|
|
||||||
каталог.
|
|
||||||
|
|
||||||
## P0-3. Один настоящий прогон релизного конвейера — без публикации владельцу
|
|
||||||
|
|
||||||
Цель — увидеть, что новые шаги `release.yml` (Release Gate → сборка →
|
|
||||||
проверка пригодности ассетов → публикация → Publication Gate) действительно
|
|
||||||
отрабатывают на GitHub Actions, а не только в теории.
|
|
||||||
|
|
||||||
1. **Не создавать публичный релиз без отдельного разрешения владельца.**
|
|
||||||
Вместо реального тега: либо (а) временный форк/ветка с ручным запуском
|
|
||||||
`workflow_dispatch`, если конвейер его поддерживает — иначе не добавлять
|
|
||||||
`workflow_dispatch` ради этого задания, это отдельное решение; либо (б)
|
|
||||||
прогнать шаги локально в том порядке, в котором их вызывает `release.yml`:
|
|
||||||
```
|
|
||||||
python scripts/release_gate.py
|
|
||||||
# сборка через build_installer.ps1 в dist/
|
|
||||||
python scripts/release_gate.py --assets dist
|
|
||||||
```
|
|
||||||
и явно объяснить, что осталось непроверенным без настоящей публикации
|
|
||||||
(шаг `Publish GitHub Release` и `--publication-only` после него).
|
|
||||||
2. Если владелец в диалоге явно разрешит настоящий тестовый тег — тогда можно
|
|
||||||
довести до конца, включая `release_gate.py --publication-only` на
|
|
||||||
опубликованном релизе. **Без этого разрешения — не пушить тег.**
|
|
||||||
3. Итог — что именно проверено, а что нет и почему (например: «сборка и
|
|
||||||
проверка пригодности ассетов проверены локально в точности как в
|
|
||||||
`release.yml`; публикация и Publication Gate не проверены — нужен реальный
|
|
||||||
тег, разрешения не спрашивал/владелец отказал»).
|
|
||||||
|
|
||||||
## P0-4. Проверка исполнением, а не по чтению кода
|
|
||||||
|
|
||||||
Как и в HUB-1: там, где что-то не запускалось — не писать «должно работать»,
|
|
||||||
запустить и приложить вывод. Не удалось — сказать `Н/Д` с точной причиной
|
|
||||||
(например: «на этой машине нет .NET Framework 4.0, только .NET 8» — если это
|
|
||||||
окажется так).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- **Не публиковать релиз на GitHub без явного разрешения владельца в этом
|
|
||||||
диалоге.** Прогон `release.yml` через настоящий тег создаёт публичный релиз.
|
|
||||||
- Реальные учётные данные и `~/.hermes/agy_profiles/` не удалять и не менять
|
|
||||||
ради эксперимента; для изоляции — переменные окружения, как в существующих
|
|
||||||
тестах.
|
|
||||||
- Правки ревьюера из `main` не откатывать; правки HUB-1 (P0-1, P0-2 из этого
|
|
||||||
задания опираются на них) не переписывать без причины.
|
|
||||||
- Версию `0.1.3` не понижать и не менять без необходимости.
|
|
||||||
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной.
|
|
||||||
- Если для `workflow_dispatch` нужно менять `.github/workflows/release.yml` —
|
|
||||||
делать это отдельным, явно описанным шагом, не молча.
|
|
||||||
|
|
||||||
## Критерии приёмки
|
|
||||||
|
|
||||||
1. Ветка в `origin`, `git status` чист.
|
|
||||||
2. `dist/HermesHubSetup.exe` собран на настоящей Windows-машине; вывод сборки
|
|
||||||
приложен.
|
|
||||||
3. Все installer-тесты (`pytest -m installer` + компиляция C#) выполнены не
|
|
||||||
как `SKIPPED`; вывод каждого приложен.
|
|
||||||
4. `HKCU\...\Uninstall\HermesHub` до и после прогона тестов идентичен —
|
|
||||||
оба снятых состояния приложены.
|
|
||||||
5. `/silent` установка проверена на реальном профиле; коды возврата названы
|
|
||||||
со сценарием каждого.
|
|
||||||
6. Локальный прогон шагов `release.yml` (Release Gate → сборка → проверка
|
|
||||||
ассетов) воспроизведён и приложен; либо — с явного разрешения владельца —
|
|
||||||
доведён до настоящего тега и `--publication-only`.
|
|
||||||
7. Каждый непроверенный пункт назван явно, с причиной — не пропущен молча.
|
|
||||||
8. Отчёт: что собрано, что запущено, точные команды и их вывод, что осталось
|
|
||||||
`Н/Д` и почему.
|
|
||||||
|
|
||||||
## Главное
|
|
||||||
|
|
||||||
HUB-1 сделал ворота честными на уровне кода: они больше не заявляют проверку,
|
|
||||||
которой не было. Это задание проверяет ту же честность на уровне машины —
|
|
||||||
что установщик, который получит владелец, действительно собирается, ставится
|
|
||||||
и обновляется так, как об этом говорит код. Пока это не проверено на
|
|
||||||
настоящей Windows, «зелёный CI» доказывает только код, а не установщик.
|
|
||||||
|
|
||||||
## Порядок сдачи
|
|
||||||
Передать точный `FINAL_COMMIT_SHA` и полный вывод всех проверок из P0-1—P0-3.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# Разведка и идеи
|
|
||||||
|
|
||||||
Здесь — то, что рассмотрено для Hermes Hub, но ещё не решено делать: чужие
|
|
||||||
проекты, откуда стоит перенять устройство, наблюдения из новостей, отложенные
|
|
||||||
замыслы. Отдельно от `ARCHITECTURE.md`: тот описывает **что построено**, а этот
|
|
||||||
каталог — **что взвешено и почему**.
|
|
||||||
|
|
||||||
Правило одно: сюда попадает только то, что проверено рассуждением и привязано к
|
|
||||||
нашей машине и нашим ограничениям, а не пересказ рекламных обещаний. У каждой
|
|
||||||
записи — дата, вердикт и причина вердикта. Устаревшее не удалять молча:
|
|
||||||
помечать, что и когда изменилось.
|
|
||||||
|
|
||||||
## Записи
|
|
||||||
|
|
||||||
- [agent-orchestrator.md](agent-orchestrator.md) — разбор Agent Orchestrator,
|
|
||||||
ближайшего архитектурного родственника Hub; что перенять, чего не брать.
|
|
||||||
- [scouting-log.md](scouting-log.md) — журнал разведки: что рассмотрено по датам,
|
|
||||||
с вердиктом по каждому пункту и причиной.
|
|
||||||
- [kagent-merge-decision.md](kagent-merge-decision.md) — решение о слиянии с
|
|
||||||
KAgent: проверенные ревьюером находки, жёсткий гейт на перенос ключей, порядок.
|
|
||||||
- [kagent-merge-plan.md](kagent-merge-plan.md) — сам план миграции Hermes → KAgent
|
|
||||||
по фазам, положен в репозиторий как артефакт.
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# Разбор: Agent Orchestrator
|
|
||||||
|
|
||||||
**Источник:** сводка AI Daily News за 2026-08-31.
|
|
||||||
**Лицензия по сводке:** Apache 2.0, локальный запуск.
|
|
||||||
**Вердикт:** разобрать устройство и перенять две идеи; целиком не брать.
|
|
||||||
**Дата разбора:** 2026-09-02.
|
|
||||||
|
|
||||||
> Первоисточник перед внедрением перепроверить: сводка — это пересказ, а не сам
|
|
||||||
> проект. Ссылку и точное имя репозитория подтвердить.
|
|
||||||
|
|
||||||
## Почему он для нас важен
|
|
||||||
|
|
||||||
Это ближайший архитектурный родственник того, что строит Hermes Hub. Он тоже
|
|
||||||
раздаёт работу флоту coding-агентов (Claude Code, Codex, Aider, OpenCode, Cline,
|
|
||||||
Continue, Goose и другие) и держит постоянную роль оркестратора — планирование,
|
|
||||||
делегирование, координация, — а работники заняты реализацией, тестами, PR и
|
|
||||||
исправлениями. Ровно наша схема ролей из `ROUTER.md`.
|
|
||||||
|
|
||||||
Ценность не в том, чтобы взять его вместо Hub, а в том, что он уже прошёл путь,
|
|
||||||
на котором мы сейчас: у нас роли есть, но замечания исполнителю пока возвращает
|
|
||||||
живой ревьюер вручную.
|
|
||||||
|
|
||||||
## Что перенять
|
|
||||||
|
|
||||||
### 1. Возврат замечаний исполнителю — то, чего у нас нет
|
|
||||||
|
|
||||||
У них отказы CI и замечания ревью **маршрутизируются обратно нужному агенту**, и
|
|
||||||
петля замыкается автоматически. У нас этого звена нет: агент присылает отчёт,
|
|
||||||
ревьюер проверяет исполнением, находит расхождение — и передаёт правку заново
|
|
||||||
руками. За 1–2 сентября так было трижды (A56, A57, ложный «16/16»).
|
|
||||||
|
|
||||||
Стоит спроектировать: результат проверки (прошло / не прошло + причина + номер
|
|
||||||
задания) возвращается тому исполнителю и в ту ветку, откуда пришла работа. Это
|
|
||||||
естественное продолжение линии заданий, которую ведёт `agents/inbox`.
|
|
||||||
|
|
||||||
### 2. Ветка и worktree на каждого работника
|
|
||||||
|
|
||||||
Каждый работник получает отдельный git worktree, отдельную ветку и отдельное
|
|
||||||
состояние сессии. Мы это уже делаем вручную — каждое задание Antigravity живёт в
|
|
||||||
своей ветке `antigravity/aNN-*`, ревьюер сливает в `main`. У них это часть
|
|
||||||
системы, а не ручной обычай. Формализовать наш обычай стоит.
|
|
||||||
|
|
||||||
### 3. Отслеживание PR / CI / merge conflicts / состояния работника
|
|
||||||
|
|
||||||
Единая доска состояния флота. У нас это разбросано: `agents/inbox`,
|
|
||||||
`agents/done`, `agents/reports` и git-ветки. Свести в один обзор — понятная
|
|
||||||
польза, когда исполнителей несколько и они на разных машинах.
|
|
||||||
|
|
||||||
## Чего НЕ брать
|
|
||||||
|
|
||||||
- **Целиком как замену Hub.** Hub — маршрутизатор провайдеров внутри Hermes
|
|
||||||
Agent, а не автономная IDE для флота. Задачи пересекаются, но не совпадают.
|
|
||||||
- **Прямой доступ агентов к рабочему окружению без ограничений.** Здесь держать
|
|
||||||
в уме вывод из отчёта OpenAI Astra (сводка за 2026-09-02): автономным агентам
|
|
||||||
ограничения должны обеспечиваться инфраструктурой, а не системным запросом. У
|
|
||||||
нас противовес уже есть — изоляция из A37 и правило про `agy_profiles`;
|
|
||||||
ослаблять его ради удобства оркестрации нельзя.
|
|
||||||
|
|
||||||
## Что проверить у первоисточника перед любым внедрением
|
|
||||||
|
|
||||||
- точное имя и адрес репозитория, реальную лицензию (в коде, не в сводке);
|
|
||||||
- как именно возвращаются замечания — формат, транспорт, привязка к ветке;
|
|
||||||
- требования к окружению и зрелость проекта (возраст, тесты, сообщество);
|
|
||||||
- модель прав: что работник может делать с репозиторием и системой.
|
|
||||||
|
|
||||||
## Связанное
|
|
||||||
|
|
||||||
- Роли и цепочки: [../ROUTER.md](../ROUTER.md).
|
|
||||||
- Изоляция агентов и запрет на чужие ключи: A37, `agents/inbox/2026-08-30-A37-*`.
|
|
||||||
- Наблюдаемость сессий агентов — см. AgentsView в
|
|
||||||
[scouting-log.md](scouting-log.md).
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
# Решение: слияние Hermes Hub и KAgent
|
|
||||||
|
|
||||||
**Дата:** 2026-09-02.
|
|
||||||
**Статус:** направление принято владельцем; исполнение — по условиям ниже.
|
|
||||||
**Ревьюер проверил исполнением** обе стороны, насколько имел доступ.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Решение
|
|
||||||
|
|
||||||
Вести один продукт — **KAgent** как единую AI-платформу. Функциональность
|
|
||||||
Hermes Hub переносится в KAgent нативно, Hermes Hub после достижения parity
|
|
||||||
архивируется. Полный план — [kagent-merge-plan.md](kagent-merge-plan.md).
|
|
||||||
|
|
||||||
Организация работы на переходный период (решение владельца от 2026-09-02):
|
|
||||||
|
|
||||||
- **KAgent** дорабатывается на одном сервере;
|
|
||||||
- **Hermes Hub** доводится на втором сервере;
|
|
||||||
- после доработки — слияние;
|
|
||||||
- **KAgent готовится к слиянию сразу**, с первого дня: контракты и модель
|
|
||||||
безопасности проектируются под будущий перенос, а не подгоняются потом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что ревьюер проверил сам, а не взял из аудита
|
|
||||||
|
|
||||||
Аудиты — тоже отчёты, поэтому проверены исполнением. Спот-проверка совпала с
|
|
||||||
аудитами на конкретных утверждениях — значит доверять им можно, но с поправками
|
|
||||||
ниже.
|
|
||||||
|
|
||||||
### Hermes Hub
|
|
||||||
|
|
||||||
- **CI на `main` красный** — подтверждено, несколько падений 2026-09-02.
|
|
||||||
- **Баг pricing fallback реален**: `telemetry_service.py:164` делает
|
|
||||||
`yaml.safe_dump(p.read_text(...))` вместо `safe_load`, затем проверяет
|
|
||||||
`isinstance(data, dict)` — всегда ложно, и `except: pass` это глушит. Таблица
|
|
||||||
цен из `pricing.yaml` не загружается никогда. Аудит: P2. Подтверждено.
|
|
||||||
|
|
||||||
### KAgent (репозиторий `ochenstarik-ui/kagent`, head `131c9b08`)
|
|
||||||
|
|
||||||
- **Лицензии нет**, репозиторий публичный — подтверждено.
|
|
||||||
- **Публичный расход средств подтверждён чтением `services/reasoning-engine/src/server.py`:**
|
|
||||||
функция `require_operator_secret` существует и применяется к управлению
|
|
||||||
аккаунтами (`/v1/accounts`, `pin`, `disable`, `reset-throttle`), но **НЕ**
|
|
||||||
применяется к `/v1/execute`, `/v1/decide`, `/v1/telemetry`, `/v1/models`.
|
|
||||||
`/v1/execute` вызывает `engine.execute(...)` — реальный расход. То есть с
|
|
||||||
подключёнными ключами любой, кто найдёт порт, тратит квоты без авторизации.
|
|
||||||
Это не гипотеза, а код на `main`.
|
|
||||||
- **Поправка к аудиту:** аудит датирован 2026-09-02 и говорит об «активности
|
|
||||||
после релиза», но последний push в KAgent — **18–19 августа**, две недели
|
|
||||||
тишины. CI зелёный, но старый. KAgent сейчас не разрабатывается активно. На
|
|
||||||
выводы о коде это не влияет (head-коммит совпал), на планирование сроков —
|
|
||||||
влияет.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Жёсткий гейт (не обсуждается)
|
|
||||||
|
|
||||||
**Ни один ключ провайдера не переезжает в KAgent, пока `/v1/execute`,
|
|
||||||
`/v1/decide` и `/v1/telemetry` не закрыты авторизацией и это не проверено живым
|
|
||||||
запросом.** У владельца ~2 десятка оплаченных аккаунтов. Пока маршруты открыты,
|
|
||||||
KAgent небезопасен даже без слияния — это надо чинить в нём независимо.
|
|
||||||
|
|
||||||
Это соответствует Phase 0 плана слияния и P0 аудита KAgent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Порядок, который советует ревьюер
|
|
||||||
|
|
||||||
Направление верное — два оркестратора не нужны, Крона не должна знать о Hermes.
|
|
||||||
Но последовательность важнее скорости:
|
|
||||||
|
|
||||||
1. **Hermes довести до зелёного и стабильного** прежде, чем замораживать. Он —
|
|
||||||
эталон переноса (reference implementation). Сломанный эталон нельзя
|
|
||||||
портировать: parity-тесты будут сверяться с неверным поведением. Сегодня
|
|
||||||
Hermes ещё нестабилен — аккаунты едва работают, `agy`-патч слетает после
|
|
||||||
перезагрузки, `main` красный.
|
|
||||||
2. **KAgent Phase 0 (безопасность) — закрыть и проверить исполнением**, начиная
|
|
||||||
ровно с четырёх незакрытых маршрутов. До этого — никаких ключей.
|
|
||||||
3. **Контракт выполнения (Phase 1)** можно проектировать уже сейчас, риска нет:
|
|
||||||
`AIExecutionRequest`, `AIExecutionResult`, `ProviderAdapter`, таксономия
|
|
||||||
ошибок, `RoutingDecision`.
|
|
||||||
|
|
||||||
## Почему «rewrite не нужен» — неточность
|
|
||||||
|
|
||||||
Скелет KAgent есть, но роутер Hermes не портируется построчно: он переезжает в
|
|
||||||
другую архитектуру (Rust gateway, TS control plane, Python-сервисы,
|
|
||||||
распределённое состояние Redis/Postgres/NATS вместо процесса). Это честный
|
|
||||||
rewrite роутера. Сроки планировать от этого.
|
|
||||||
|
|
||||||
## Что перенести из Hermes (проверенные тонкости, легко потерять при переносе)
|
|
||||||
|
|
||||||
Эти вещи вскрылись только живым прогоном и обязаны попасть в parity-набор:
|
|
||||||
|
|
||||||
- вход `agy` читается из `.gemini/antigravity-cli/antigravity-oauth-token`, не из
|
|
||||||
формата Gemini CLI;
|
|
||||||
- терминалу входа нельзя подменять `HOME` (X11 берёт ключ из `~/.Xauthority`);
|
|
||||||
- `/props` и `/tokenize` у llama.cpp — в корне, не под `/v1`;
|
|
||||||
- слот выбирается до входа и не должен плодиться; запрос пути профиля не должен
|
|
||||||
создавать каталог;
|
|
||||||
- проверка после подключения не блокирует ответ;
|
|
||||||
- честное `Н/Д` с причиной вместо правдоподобных чисел.
|
|
||||||
|
|
||||||
Подробности — в [../../agents/](../../agents/) и передаточном брифе.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Координация
|
|
||||||
|
|
||||||
Две сессии Claude пишут в один `main` Hermes Hub (сессия на ПК — ревьюер; сессия
|
|
||||||
на сервере под `ochenstarik` — исполнитель). Плюс крупный разворот стратегии.
|
|
||||||
Обе сессии должны видеть это решение. Перед пушем — `git fetch` и сверка
|
|
||||||
`git log --oneline origin/main`.
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# План слияния Hermes Hub → KAgent
|
|
||||||
|
|
||||||
Источник — план владельца от 2026-09-02, положен в репозиторий, чтобы не жил
|
|
||||||
только файлом на рабочем столе. Оценка и условия исполнения — в
|
|
||||||
[kagent-merge-decision.md](kagent-merge-decision.md).
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
KAgent становится единой AI Agent Operating Platform. Функциональность Hermes Hub
|
|
||||||
переносится нативно, Hermes Hub и Hermes Agent перестают быть зависимостями,
|
|
||||||
Hermes Hub архивируется. Hermes Hub на переходный период — донор функциональности
|
|
||||||
и эталон поведения, не встраиваемая библиотека.
|
|
||||||
|
|
||||||
## Разделение обязанностей
|
|
||||||
|
|
||||||
- **Orchestrator** выбирает агента, workflow, инструменты, контекст, проверку,
|
|
||||||
момент завершения.
|
|
||||||
- **AI Router** выбирает провайдера, модель, аккаунт, локально/облако, failover,
|
|
||||||
проверяет квоту, доступность, бюджет, вычислительный узел.
|
|
||||||
|
|
||||||
> Orchestrator выбирает агента и задачу. Router выбирает модель, провайдера и
|
|
||||||
> аккаунт.
|
|
||||||
|
|
||||||
## Что переносится из Hermes
|
|
||||||
|
|
||||||
Multi-provider router; адаптеры провайдеров (Antigravity, Claude, Codex,
|
|
||||||
DeepSeek, Grok, Local, NVIDIA, Ollama, OpenCode, OpenRouter); менеджер
|
|
||||||
аккаунтов/профилей (несколько аккаунтов на провайдера, приоритет, quota,
|
|
||||||
cooldown, health, concurrency); health-состояния; quota manager; session
|
|
||||||
affinity; lease/concurrency; model registry; capability-routing; локальные
|
|
||||||
модели и вычислительные узлы; agent registry (15 ролей как декларативные
|
|
||||||
Agent Definition); Dual Coder как workflow; Guardian как policy-слой; Cost
|
|
||||||
Controller как системная подсистема; failover-policy с таксономией ошибок;
|
|
||||||
telemetry в существующий Observability; audit routing-решений.
|
|
||||||
|
|
||||||
## Что НЕ переносить
|
|
||||||
|
|
||||||
Hermes-specific bootstrap; дублирующий Web API и отдельный UI; process-local
|
|
||||||
архитектуру; JSONL как основное хранилище telemetry; формат настроек Hermes;
|
|
||||||
update flow Hermes; роль orchestrator как отдельный runtime; код, привязанный к
|
|
||||||
структуре Hermes Agent; compatibility-слои, не нужные после миграции.
|
|
||||||
|
|
||||||
Секреты: не переносить хранилище Hermes один-в-один. Порядок — внешний Secret
|
|
||||||
Manager → OS/keyring → шифрованное хранение в БД → материализация только на время
|
|
||||||
запроса. Запрещено: ключи в обычных JSON, отдача секретов через API, секреты в
|
|
||||||
telemetry/audit/трейсах.
|
|
||||||
|
|
||||||
## Фазы
|
|
||||||
|
|
||||||
- **Phase 0 — Security baseline (блокер).** Auth/RBAC; защита расхода провайдера;
|
|
||||||
service-auth; безопасное хранение секретов; уникальная request identity;
|
|
||||||
реальный E2E. Выход: нет неавторизованного execution и мутаций проекта/задачи;
|
|
||||||
расход защищён; CI зелёный; E2E по настоящему пути зелёный.
|
|
||||||
- **Phase 1 — контракты Router.** AIExecutionRequest, AIExecutionResult,
|
|
||||||
ProviderAdapter, Model/Account descriptor, таксономия ошибок, RoutingDecision.
|
|
||||||
Провайдеры пока не переносить. Выход: contract-тесты, fake-адаптер, роутер на
|
|
||||||
тестовых провайдерах.
|
|
||||||
- **Phase 2 — Provider SDK.** timeout, cancellation, streaming, маппинг ошибок,
|
|
||||||
usage, cost, health, discovery. Выход: новый провайдер добавляется без правки
|
|
||||||
ядра.
|
|
||||||
- **Phase 3 — перенос адаптеров.** Порядок: openai-compatible → Claude →
|
|
||||||
OpenRouter → Google/Antigravity → Grok → DeepSeek → NVIDIA → Ollama → Codex →
|
|
||||||
OpenCode → local. Для каждого: parity, таксономия ошибок, health/auth/
|
|
||||||
streaming/timeout/quota/regression тесты.
|
|
||||||
- **Phase 4 — Account Manager.** Безопасные credentials, приоритет, quota,
|
|
||||||
cooldown, health, concurrency, переходы состояний.
|
|
||||||
- **Phase 5 — Router Engine.** role/capability routing, scoring, preferred chain,
|
|
||||||
health/quota awareness, same-account и cross-account/provider fallback, session
|
|
||||||
affinity, auto-return primary, failover trace.
|
|
||||||
- **Phase 6 — распределённое состояние.** Redis (health, leases, affinity,
|
|
||||||
cooldown), PostgreSQL (providers, accounts, models, policies, budgets, usage,
|
|
||||||
nodes, agents).
|
|
||||||
- **Phase 7 — локальные модели / compute nodes.** node agent: регистрация,
|
|
||||||
heartbeat, инвентарь моделей и ресурсов, execution, queue, GPU.
|
|
||||||
- **Phase 8 — Agent Registry.** декларативные определения: capabilities, tools,
|
|
||||||
permissions, routing policy, budgets, model constraints.
|
|
||||||
- **Phase 9 — Guardian** как policy enforcement: валидация команд, границы ФС,
|
|
||||||
сигналы prompt injection, детект секретов, проверка прав инструментов, сетевая
|
|
||||||
политика, классификация разрушительных действий.
|
|
||||||
- **Phase 10 — Cost Controller.** оценочная и фактическая стоимость, жёсткие
|
|
||||||
бюджеты, наследование, cloud/local оптимизация, alerts, kill switch.
|
|
||||||
- **Phase 11 — Workflows.** Dual Coder как workflow; шаблоны Coder+Reviewer,
|
|
||||||
Coder+Tester, Security Review, Multi-model Consensus, Local Draft + Cloud
|
|
||||||
Review.
|
|
||||||
- **Phase 12 — UI.** разделы Providers, Accounts, Models, Routing, Nodes, Quotas,
|
|
||||||
Budgets, Usage, Health, Agents.
|
|
||||||
- **Phase 13 — parity-тесты.** Чек-лист до отключения Hermes: все провайдеры,
|
|
||||||
несколько аккаунтов, quota exhaustion, rate limit, auth failure, failover,
|
|
||||||
локальные модели, session affinity, выбор модели, health, telemetry, Dual
|
|
||||||
Coder, роли.
|
|
||||||
- **Phase 14 — decommission Hermes.** запрет новых фич → deprecated → KAgent
|
|
||||||
единственный production path → удаление зависимостей → финальный релиз Hermes →
|
|
||||||
archived.
|
|
||||||
|
|
||||||
## Критерий отказа от Hermes
|
|
||||||
|
|
||||||
Архивировать только когда KAgent умеет: все нужные облачные провайдеры;
|
|
||||||
несколько аккаунтов; локальный AI; авто-выбор модели; failover; учёт quota;
|
|
||||||
health; session affinity; telemetry; расчёт cost; роли агентов; эквивалент Dual
|
|
||||||
Coder; проверки Guardian; бюджеты; работу на Windows/Linux; полный parity-набор.
|
|
||||||
|
|
||||||
## Обязательные P0 KAgent до миграции (из его аудита, часть проверена ревьюером)
|
|
||||||
|
|
||||||
Control Plane auth/RBAC (не доверять `x-actor-id`); авторизация
|
|
||||||
`/v1/execute` и `/v1/decide` (**подтверждено: сейчас открыты**); уникальная
|
|
||||||
request identity; фикс double-consume TOTP; настоящий E2E через Gateway, не mock;
|
|
||||||
добавить LICENSE (**подтверждено: отсутствует**).
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# Журнал разведки
|
|
||||||
|
|
||||||
Что рассмотрено для Hermes Hub, с вердиктом и причиной. Причина важнее вердикта:
|
|
||||||
она объясняет, почему решение такое, и не даёт вернуться к отвергнутому через
|
|
||||||
месяц, забыв доводы.
|
|
||||||
|
|
||||||
Наша машина, чтобы вердикты были понятны: сервер — одна **Tesla V100 32 ГиБ**
|
|
||||||
(Volta, sm_70), кодер Qwen3-Coder-30B-A3B занимает ~30 ГиБ из 32, свободно ~2.
|
|
||||||
Провайдеры подключаются аккаунтами через хаб.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-09-02
|
|
||||||
|
|
||||||
### Взять
|
|
||||||
|
|
||||||
**Claude Fable 5.1** — новая frontier-модель Anthropic под длительные
|
|
||||||
coding/agent-задачи. Цена $10 / $50 за миллион (вход/выход). Ложится на нашу
|
|
||||||
маршрутизацию по ролям как ревьюер и второй кодер; на повседневное не ставить
|
|
||||||
из-за цены. Проверяется через уже подключённый аккаунт Claude, отдельного
|
|
||||||
провайдера не требует. **Вердикт: протестировать на одной сложной задаче,
|
|
||||||
сравнить с текущими кодером и ревьюером по времени, стоимости и качеству.**
|
|
||||||
|
|
||||||
### Внимание, не улучшение
|
|
||||||
|
|
||||||
**Hermes Agent v0.21.0** — не обновлять сервер первым. В сводке два бага:
|
|
||||||
`ollama_num_ctx` может ограничить контекст облачного провайдера локальным
|
|
||||||
лимитом Ollama (в отчёте 65 536 при заявленном 1M); shutdown-watchdog зовёт
|
|
||||||
`asyncio.start_unix_server`, которого нет в родном Python под Windows. Проверено:
|
|
||||||
ни `AF_UNIX`, ни `ollama_num_ctx` в коде хаба нет — оба дефекта в самом Hermes
|
|
||||||
Agent, но хаб работает его плагином. **Отдельно:** 65 536 — это ещё и наше
|
|
||||||
запасное значение `n_ctx` в `local_supervisor.query_server_props`, когда `/props`
|
|
||||||
недоступен. Числа совпадают, дефекты разные — не перепутать при разборе.
|
|
||||||
**Вердикт: сервер обновлять последним, после Windows-машины (canary first).**
|
|
||||||
|
|
||||||
### Перенять устройство
|
|
||||||
|
|
||||||
**Agent Orchestrator** (Apache 2.0, локальный) — ближайший родственник Hub.
|
|
||||||
Подробный разбор: [agent-orchestrator.md](agent-orchestrator.md). Коротко:
|
|
||||||
перенять автоматический возврат замечаний исполнителю (у нас его нет), ветку и
|
|
||||||
worktree на работника, единую доску состояния флота; целиком не брать.
|
|
||||||
|
|
||||||
**Наблюдаемость — паттерн NVIDIA BioNeMo / Claude Science:** оркестратор + узкие
|
|
||||||
инструменты вместо «одна модель делает всё». Архитектурно полезно для Кроны, не
|
|
||||||
для Hub напрямую. **Вердикт: держать в уме для Кроны.**
|
|
||||||
|
|
||||||
### Отложить
|
|
||||||
|
|
||||||
**AgentsView** (MIT, локальный, без аккаунта) — местная аналитика сессий
|
|
||||||
coding-агентов: единый индекс, SQLite, токены и стоимость по агентам и датам,
|
|
||||||
сбор с нескольких машин. Полезно: у нас теперь сессии на двух машинах и
|
|
||||||
несколько исполнителей (Claude, Antigravity, Codex), и вопрос «куда ушли токены»
|
|
||||||
встанет скоро. Риск низкий. **Вердикт: протестировать локально, когда дойдут
|
|
||||||
руки; не срочно, пока хаб не стабилизирован.**
|
|
||||||
|
|
||||||
### Не для нас сейчас
|
|
||||||
|
|
||||||
**ARD (Agentic Resource Discovery)** — слой обнаружения инструментов, чтобы не
|
|
||||||
грузить весь каталог в prompt. Идея верная, но преждевременная: сначала хаб
|
|
||||||
должен надёжно видеть подключённые аккаунты. **Вердикт: следить за стандартом,
|
|
||||||
не внедрять.**
|
|
||||||
|
|
||||||
**ComfyUI MCP, VoiceStudio, Gemini Agentic Video, DreamX-Creator** — медиа и
|
|
||||||
видео. Не про Hermes Hub; относится к SMM/медийным проектам среди прочих
|
|
||||||
каталогов `/srv/projects`. **Вердикт: не в Hub.**
|
|
||||||
|
|
||||||
**Rapid-MLX, MLX Workbench, CAVI MLX Agent** — только Apple Silicon. Mac-узла
|
|
||||||
нет. **Вердикт: наблюдать до появления Mac.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-09-01
|
|
||||||
|
|
||||||
### Проверять, а не брать на веру
|
|
||||||
|
|
||||||
**llama.cpp свежие сборки** (flash-attention под CUDA, shared-memory K/V,
|
|
||||||
MoE-fusion). Новые пути CUDA обычно рассчитаны на Ampere и новее; V100 — Volta
|
|
||||||
(sm_70), выигрыш не гарантирован. **Вердикт: если пробовать — только с повторным
|
|
||||||
замером тех же 107,4 ток/с на нашей сборке; по умолчанию не выигрыш.**
|
|
||||||
|
|
||||||
**Qwen3.8-27B MTP / спекулятивное декодирование** — приведённые замеры на RTX
|
|
||||||
3090 и 5090. У нас черновой модели некуда встать: кодер занимает 30 ГиБ из 32. И
|
|
||||||
в A52 уже измерено — две модели на одной V100 делят пропускную способность
|
|
||||||
памяти 0,78–0,99×, ускорения нет. **Вердикт: не тратить время на нашей машине.**
|
|
||||||
|
|
||||||
### Паттерн, не продукт
|
|
||||||
|
|
||||||
**AWS Agent Toolkit** — паттерн `Skills + restricted MCP + policy + audit`.
|
|
||||||
Совпадает с линией изоляции A37 и моделью безопасности (`SECURITY_MODEL.md`).
|
|
||||||
**Вердикт: держать как ориентир для Tool/Skill-подсистемы, продукт не тащить.**
|
|
||||||
|
|
||||||
**TradingAgents v0.4.0** — не для Hub; идеи point-in-time (защита от заглядывания
|
|
||||||
в будущее, historical snapshot, отметки времени в памяти решений) — для
|
|
||||||
финансовых проектов. **Вердикт: не в Hub, передать в торговые проекты.**
|
|
||||||
|
|
@ -18,7 +18,7 @@ namespace HermesHubSetup
|
||||||
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
||||||
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
||||||
// какого кода собран установщик.
|
// какого кода собран установщик.
|
||||||
public const string BuildCommit = "e431e39";
|
public const string BuildCommit = "90aeceb";
|
||||||
public const string MIN_HERMES_VERSION = "0.20.0";
|
public const string MIN_HERMES_VERSION = "0.20.0";
|
||||||
public const string MAX_TESTED_HERMES = "0.20.4";
|
public const string MAX_TESTED_HERMES = "0.20.4";
|
||||||
|
|
||||||
|
|
@ -86,17 +86,7 @@ namespace HermesHubSetup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFolderPath(LocalApplicationData) не всегда возвращает то, что
|
string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub");
|
||||||
// ждёт установщик — найдено живым прогоном (A61): в некоторых
|
|
||||||
// окружениях (изолированный тестовый профиль, нестандартный
|
|
||||||
// пользовательский куст реестра) значение расходится с
|
|
||||||
// фактическим %LOCALAPPDATA%. Читаем переменную окружения первой.
|
|
||||||
string localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
|
||||||
if (string.IsNullOrEmpty(localAppData))
|
|
||||||
{
|
|
||||||
localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
|
||||||
}
|
|
||||||
string defaultTarget = Path.Combine(localAppData, @"Programs\HermesHub");
|
|
||||||
TargetInstallDir = defaultTarget;
|
TargetInstallDir = defaultTarget;
|
||||||
|
|
||||||
// Check if already installed
|
// Check if already installed
|
||||||
|
|
@ -605,12 +595,6 @@ namespace HermesHubSetup
|
||||||
|
|
||||||
private static void CreateStartMenuShortcut()
|
private static void CreateStartMenuShortcut()
|
||||||
{
|
{
|
||||||
// Изолированные прогоны (HERMES_HUB_NO_REGISTRY=1) уже не пишут в
|
|
||||||
// реестр (см. HERMES_HUB_NO_REGISTRY ниже), но ярлык в настоящем
|
|
||||||
// меню Пуск владельца этим не перекрывался — найдено живым
|
|
||||||
// прогоном тестов на A61: /silent-тест с этой переменной всё
|
|
||||||
// равно оставлял значок в реальном Пуск.
|
|
||||||
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||||
|
|
@ -657,7 +641,6 @@ namespace HermesHubSetup
|
||||||
|
|
||||||
private static void RemoveStartMenuShortcut()
|
private static void RemoveStartMenuShortcut()
|
||||||
{
|
{
|
||||||
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,51 @@ echo ""
|
||||||
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
|
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
|
||||||
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
|
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
|
||||||
echo "[0/6] Остановка работающего Hermes Hub..."
|
echo "[0/6] Остановка работающего Hermes Hub..."
|
||||||
# shellcheck source=./lib_stop_running_hub.sh
|
|
||||||
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
|
stop_running_hub() {
|
||||||
|
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
|
||||||
|
local pids
|
||||||
|
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
|
||||||
|
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||||
|
' ' ')"
|
||||||
|
|
||||||
|
if [ -z "$pids" ]; then
|
||||||
|
echo " Работающий хаб не найден — останавливать нечего."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Найдены процессы хаба: $pids"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill $pids 2>/dev/null || true
|
||||||
|
|
||||||
|
local waited=0
|
||||||
|
while [ "$waited" -lt 10 ]; do
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||||
|
' ' ')"
|
||||||
|
[ -z "$pids" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill -9 $pids 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||||
|
' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
# Не прерываем установку: файлы обновятся, а владельцу скажем правду.
|
||||||
|
echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Хаб остановлен."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
stop_running_hub || true
|
stop_running_hub || true
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|
@ -223,35 +266,9 @@ mkdir -p "$HERMES_HOME/bin"
|
||||||
cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web"
|
cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web"
|
||||||
chmod +x "$HERMES_HOME/bin/hermes-hub-web"
|
chmod +x "$HERMES_HOME/bin/hermes-hub-web"
|
||||||
|
|
||||||
# Лаунчер остановки — эквивалент «Exit» из системного трея Windows.
|
|
||||||
#
|
|
||||||
# На Windows сервер стартует из HermesHubWeb.exe, который держит значок в
|
|
||||||
# трее: закрыть его оттуда может сам владелец. На Linux сервер после закрытия
|
|
||||||
# окна остаётся в фоне без единого способа его остановить — ни кнопки в
|
|
||||||
# интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню. Кладём
|
|
||||||
# lib_stop_running_hub.sh рядом со скриптом остановки: он ищет её сначала
|
|
||||||
# рядом с собой.
|
|
||||||
STOP_LAUNCHER_SRC="$REPO_ROOT/launcher/hermes-hub-stop.sh"
|
|
||||||
if [ ! -f "$STOP_LAUNCHER_SRC" ]; then
|
|
||||||
STOP_LAUNCHER_SRC="$SCRIPT_DIR/../launcher/hermes-hub-stop.sh"
|
|
||||||
fi
|
|
||||||
STOP_LAUNCHER_BIN="$HOME/.local/bin/hermes-hub-stop"
|
|
||||||
if [ -f "$STOP_LAUNCHER_SRC" ]; then
|
|
||||||
cp "$STOP_LAUNCHER_SRC" "$STOP_LAUNCHER_BIN"
|
|
||||||
chmod +x "$STOP_LAUNCHER_BIN"
|
|
||||||
cp "$SCRIPT_DIR/lib_stop_running_hub.sh" "$HOME/.local/bin/lib_stop_running_hub.sh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create .desktop file
|
# Create .desktop file
|
||||||
#
|
|
||||||
# Иконка — PNG, не .ico. Измерено на настоящем GTK-рабочем столе:
|
|
||||||
# GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons
|
|
||||||
# are not supported", а .desktop-файл с несуществующей или неподдерживаемой
|
|
||||||
# иконкой Nautilus и меню приложений просто показывают пустое место — без
|
|
||||||
# ошибки, молча. Значок был бы вечно пустым на любом GTK-окружении (GNOME,
|
|
||||||
# большинство производных). PNG в тех же ассетах уже есть и загружается.
|
|
||||||
DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop"
|
DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop"
|
||||||
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/app_icon_256.png"
|
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/HermesHub.ico"
|
||||||
if [ ! -f "$ICON_PATH" ]; then
|
if [ ! -f "$ICON_PATH" ]; then
|
||||||
ICON_PATH="utilities-terminal"
|
ICON_PATH="utilities-terminal"
|
||||||
fi
|
fi
|
||||||
|
|
@ -272,28 +289,6 @@ StartupWMClass=hermes-hub-web
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod +x "$DESKTOP_FILE"
|
chmod +x "$DESKTOP_FILE"
|
||||||
|
|
||||||
# Второй пункт меню — «Остановить». Terminal=true: без окна владелец не
|
|
||||||
# увидит, остановился ли хаб на самом деле, и не заметит «⚠ Остались
|
|
||||||
# процессы» из lib_stop_running_hub.sh, если что-то пошло не так.
|
|
||||||
if [ -f "$STOP_LAUNCHER_BIN" ]; then
|
|
||||||
STOP_DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-stop.desktop"
|
|
||||||
cat <<EOF > "$STOP_DESKTOP_FILE"
|
|
||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Stop Hermes Hub
|
|
||||||
GenericName=Stop the Hermes Hub background server
|
|
||||||
Comment=Останавливает фоновый сервер Hermes Hub
|
|
||||||
Exec=$STOP_LAUNCHER_BIN
|
|
||||||
Icon=$ICON_PATH
|
|
||||||
Terminal=true
|
|
||||||
Categories=Development;Utility;
|
|
||||||
StartupNotify=false
|
|
||||||
EOF
|
|
||||||
chmod +x "$STOP_DESKTOP_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# Hermes Hub — общая функция остановки работающего хаба (Linux/POSIX).
|
|
||||||
#
|
|
||||||
# До этого файла одна и та же функция была отдельно вписана в install-linux.sh
|
|
||||||
# и в uninstall-linux.sh — две копии, которые разошлись бы при первой же
|
|
||||||
# правке одной из них незамеченной для другой. Источник источается («source»)
|
|
||||||
# обоими скриптами и лаунчером остановки, поэтому логика одна.
|
|
||||||
#
|
|
||||||
# Использование: `source "$(dirname "$0")/lib_stop_running_hub.sh"`, затем
|
|
||||||
# вызвать `stop_running_hub`. Функция сама печатает ход дела и возвращает
|
|
||||||
# 0 (остановлен или нечего было останавливать) либо 1 (что-то осталось —
|
|
||||||
# вызывающий решает, прерывать ли из-за этого).
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
stop_running_hub() {
|
|
||||||
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
|
|
||||||
local pids
|
|
||||||
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
|
|
||||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
|
||||||
|
|
||||||
if [ -z "$pids" ]; then
|
|
||||||
echo " Работающий хаб не найден — останавливать нечего."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Найдены процессы хаба: $pids"
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
kill $pids 2>/dev/null || true
|
|
||||||
|
|
||||||
local waited=0
|
|
||||||
while [ "$waited" -lt 10 ]; do
|
|
||||||
sleep 1
|
|
||||||
waited=$((waited + 1))
|
|
||||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
|
||||||
[ -z "$pids" ] && break
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
kill -9 $pids 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$pids" ]; then
|
|
||||||
echo " ⚠ Остались процессы: $pids. Снимите их вручную."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Хаб остановлен."
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
||||||
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
||||||
|
|
||||||
|
|
@ -25,35 +23,18 @@ echo "======================================================================"
|
||||||
echo "Hermes Home : $HERMES_HOME"
|
echo "Hermes Home : $HERMES_HOME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 0. Остановка работающего хаба.
|
|
||||||
#
|
|
||||||
# Тот же порядок, что в install-linux.sh, и по той же причине: файлы под
|
|
||||||
# работающим процессом здесь не просто устаревают, а исчезают. С
|
|
||||||
# --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса
|
|
||||||
# открыты файловые дескрипторы — на Linux это не роняет процесс, но он
|
|
||||||
# продолжает отвечать по старому порту после «успешного» удаления, и
|
|
||||||
# следующая попытка что-то с ним сделать бьётся об уже удалённые файлы.
|
|
||||||
echo "[0/4] Остановка работающего Hermes Hub..."
|
|
||||||
# shellcheck source=./lib_stop_running_hub.sh
|
|
||||||
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
|
|
||||||
stop_running_hub || true
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 1. Remove Plugin Integration
|
# 1. Remove Plugin Integration
|
||||||
echo "[1/4] Removing plugin integration..."
|
echo "[1/3] Removing plugin integration..."
|
||||||
if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then
|
if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then
|
||||||
rm -rf "$HERMES_HOME/plugins/antigravity-provider"
|
rm -rf "$HERMES_HOME/plugins/antigravity-provider"
|
||||||
echo " Removed $HERMES_HOME/plugins/antigravity-provider"
|
echo " Removed $HERMES_HOME/plugins/antigravity-provider"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Remove Launchers and Shortcuts
|
# 2. Remove Launchers and Shortcuts
|
||||||
echo "[2/4] Removing application launchers and desktop entries..."
|
echo "[2/3] Removing application launchers and desktop entries..."
|
||||||
rm -f "$HOME/.local/bin/hermes-hub-web"
|
rm -f "$HOME/.local/bin/hermes-hub-web"
|
||||||
rm -f "$HERMES_HOME/bin/hermes-hub-web"
|
rm -f "$HERMES_HOME/bin/hermes-hub-web"
|
||||||
rm -f "$HOME/.local/bin/hermes-hub-stop"
|
|
||||||
rm -f "$HOME/.local/bin/lib_stop_running_hub.sh"
|
|
||||||
rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop"
|
rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop"
|
||||||
rm -f "$HOME/.local/share/applications/hermes-hub-stop.desktop"
|
|
||||||
|
|
||||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||||
|
|
@ -61,26 +42,17 @@ fi
|
||||||
|
|
||||||
# 3. User Data Handling
|
# 3. User Data Handling
|
||||||
if [ "$PURGE_USER_DATA" = "true" ]; then
|
if [ "$PURGE_USER_DATA" = "true" ]; then
|
||||||
echo "[3/4] Purging user data (--purge-user-data specified)..."
|
echo "[3/3] Purging user data (--purge-user-data specified)..."
|
||||||
rm -f "$HERMES_HOME/config/router_profiles.yaml"
|
rm -f "$HERMES_HOME/config/router_profiles.yaml"
|
||||||
rm -rf "$HERMES_HOME/agy_profiles"
|
rm -rf "$HERMES_HOME/agy_profiles"
|
||||||
rm -rf "$HERMES_HOME/codex_profiles"
|
rm -rf "$HERMES_HOME/codex_profiles"
|
||||||
rm -rf "$HERMES_HOME/opencode_profiles"
|
rm -rf "$HERMES_HOME/opencode_profiles"
|
||||||
echo " User configuration and profiles purged."
|
echo " User configuration and profiles purged."
|
||||||
else
|
else
|
||||||
echo "[3/4] Preserving user data and credentials."
|
echo "[3/3] Preserving user data and credentials."
|
||||||
echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact."
|
echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Post-uninstall verification: пойманный хаб действительно молчит.
|
|
||||||
echo "[4/4] Verifying no hub process remains..."
|
|
||||||
REMAINING="$(pgrep -u "$(id -u)" -f "antigravity_provider.router.web|hermes_hub_web_entry" 2>/dev/null | tr '\n' ' ')"
|
|
||||||
if [ -n "$REMAINING" ]; then
|
|
||||||
echo " ⚠ Всё ещё работает: $REMAINING — удаление файлов это не остановило."
|
|
||||||
else
|
|
||||||
echo " Хаб не работает."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX "
|
echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX "
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,49 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# ==============================================================================
|
|
||||||
# Hermes Hub — Stop (Linux)
|
|
||||||
#
|
|
||||||
# На Windows фоновый сервер запускается из HermesHubWeb.exe, который держит
|
|
||||||
# значок в системном трее — оттуда «Exit» останавливает процесс. На Linux
|
|
||||||
# сервер стартует через nohup и остаётся в фоне после закрытия окна браузера
|
|
||||||
# (так и задумано: не переустанавливать при каждом перезапуске окна), но
|
|
||||||
# остановить его после этого было решительно нечем — ни кнопки в интерфейсе
|
|
||||||
# (её нет ни на одной платформе), ни трея, ни пункта меню. Только терминал и
|
|
||||||
# pkill вручную, либо переустановка/удаление, которые останавливают хаб
|
|
||||||
# только как побочный эффект.
|
|
||||||
#
|
|
||||||
# Этот скрипт — тот недостающий эквивалент «Exit из трея»: доступен из меню
|
|
||||||
# приложений через собственный .desktop-пункт, использует ту же проверенную
|
|
||||||
# функцию остановки, что installer/install-linux.sh и uninstall-linux.sh.
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Устанавливается рядом (в ~/.hermes/bin) install-linux.sh — оттуда и берём
|
|
||||||
# общую функцию. Если скрипт запущен не из установленного места (например,
|
|
||||||
# прямо из репозитория), ищем installer/ на уровень выше.
|
|
||||||
LIB=""
|
|
||||||
for candidate in \
|
|
||||||
"$SCRIPT_DIR/lib_stop_running_hub.sh" \
|
|
||||||
"$SCRIPT_DIR/../installer/lib_stop_running_hub.sh"
|
|
||||||
do
|
|
||||||
if [ -f "$candidate" ]; then
|
|
||||||
LIB="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$LIB" ]; then
|
|
||||||
echo "❌ Не найдена installer/lib_stop_running_hub.sh — переустановите Hermes Hub." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck source=../installer/lib_stop_running_hub.sh
|
|
||||||
. "$LIB"
|
|
||||||
|
|
||||||
echo "Останавливаю Hermes Hub..."
|
|
||||||
if stop_running_hub; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
|
|
@ -23,14 +23,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||||
if str(ROOT / "src") not in sys.path:
|
if str(ROOT / "src") not in sys.path:
|
||||||
sys.path.insert(0, str(ROOT / "src"))
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
from antigravity_provider.console_encoding import force_utf8_output
|
|
||||||
from antigravity_provider.version import __version__, get_version
|
from antigravity_provider.version import __version__, get_version
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
|
|
||||||
# Отчёт ворот печатается по-русски, а консоль Windows-раннера — cp1252.
|
|
||||||
# Ставится до первого вывода: иначе падает вывод, а не проверки.
|
|
||||||
force_utf8_output()
|
|
||||||
|
|
||||||
|
|
||||||
def check_version_consistency() -> tuple[bool, str]:
|
def check_version_consistency() -> tuple[bool, str]:
|
||||||
ver = get_version()
|
ver = get_version()
|
||||||
|
|
@ -209,265 +204,88 @@ def check_security_zero_secrets() -> tuple[bool, str]:
|
||||||
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
|
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
def check_production_update_feed() -> tuple[bool, str]:
|
||||||
# Publication Gate
|
"""Live verification of public release feed manifest and package URL."""
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
#
|
|
||||||
# Проверка публикации отделена от офлайновой части, потому что раньше они были
|
|
||||||
# смешаны и обе были беззубыми. Измерено на прежней реализации:
|
|
||||||
# - при полном обрыве сети возвращался PASS ("check skipped");
|
|
||||||
# - при 404 на манифест возвращался PASS ("not yet published");
|
|
||||||
# - при 404 на пакет возвращался PASS ("pending upload");
|
|
||||||
# - при живом пакете печаталось PACKAGE_HASH_VERIFIED=True, хотя hashlib в
|
|
||||||
# файле не вызывался ни разу: скачивались байты 0-10 через заголовок Range,
|
|
||||||
# и этого хватало, чтобы объявить хеш проверенным.
|
|
||||||
# То есть ворота публикации пропускали релиз при любом исходе, включая полное
|
|
||||||
# отсутствие релиза.
|
|
||||||
#
|
|
||||||
# Теперь: офлайновые проверки (1-6) блокируют всегда; публикация проверяется
|
|
||||||
# по-настоящему — релиз есть, ассеты есть, пакет скачан целиком, SHA-256
|
|
||||||
# сошёлся с опубликованным. Блокирует она в режиме публикации (--publication
|
|
||||||
# или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для
|
|
||||||
# ветки нет и быть не должно, результат сообщается как есть и не блокирует.
|
|
||||||
# Неизмеренное называется "Н/Д" с причиной, а не выдаётся за проверенное.
|
|
||||||
|
|
||||||
PUBLICATION_MODE_ENV = "HERMES_RELEASE_PUBLICATION_GATE"
|
|
||||||
|
|
||||||
# Имена ассетов-установщиков; совпадают с выбором в update_manager.
|
|
||||||
PACKAGE_ASSET_NAMES = ("HermesHubSetup.exe", "hermes-hub-setup.sh", "install-linux.sh")
|
|
||||||
CHECKSUMS_ASSET_NAME = "checksums.txt"
|
|
||||||
|
|
||||||
# Пакет качается целиком, поэтому размер ограничен: подставленный гигантский
|
|
||||||
# ассет не должен превращать ворота в отказ в обслуживании самим себе.
|
|
||||||
MAX_PACKAGE_BYTES = 512 * 1024 * 1024
|
|
||||||
|
|
||||||
# Нижняя граница размера установщика — защита от усечённой сборки. Найдено
|
|
||||||
# живым прогоном на Windows (A61): собранный HermesHubSetup.exe считался
|
|
||||||
# готовым к публикации даже будучи почти пустым — сборка прервалась, а файл
|
|
||||||
# остался. 1 МБ — заведомо меньше любого настоящего установщика (несёт
|
|
||||||
# исходники плагина вшитым ресурсом), но отличает пустышку от файла.
|
|
||||||
MIN_PACKAGE_BYTES = 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def is_publication_mode() -> bool:
|
|
||||||
"""Требуется ли блокирующая проверка публикации."""
|
|
||||||
return "--publication" in sys.argv or os.environ.get(PUBLICATION_MODE_ENV, "") == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def _http_get(url: str, timeout: int = 30):
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
req = urllib.request.Request(
|
import urllib.error
|
||||||
url, headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
|
||||||
)
|
|
||||||
return urllib.request.urlopen(req, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _download_and_hash(url: str) -> tuple[str, int]:
|
|
||||||
"""Скачать поток целиком и посчитать SHA-256. Никаких частичных диапазонов."""
|
|
||||||
import hashlib
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
size = 0
|
|
||||||
with _http_get(url, timeout=120) as resp:
|
|
||||||
while True:
|
|
||||||
chunk = resp.read(1024 * 256)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
size += len(chunk)
|
|
||||||
if size > MAX_PACKAGE_BYTES:
|
|
||||||
raise ValueError(f"пакет превышает {MAX_PACKAGE_BYTES} байт")
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest(), size
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_local_file(path: Path) -> tuple[str, int]:
|
|
||||||
"""Посчитать SHA-256 локального файла целиком. Размер — побочный продукт."""
|
|
||||||
import hashlib
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
size = 0
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(1024 * 256)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
size += len(chunk)
|
|
||||||
digest.update(chunk)
|
|
||||||
return digest.hexdigest(), size
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_checksums(text: str) -> dict[str, str]:
|
|
||||||
"""Разобрать строки вида '<sha256> <имя файла>'."""
|
|
||||||
table: dict[str, str] = {}
|
|
||||||
for line in text.splitlines():
|
|
||||||
parts = line.strip().split()
|
|
||||||
if len(parts) >= 2 and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
|
|
||||||
table[parts[-1].lstrip("*")] = parts[0].lower()
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
def check_offline_update_contract() -> tuple[bool, str]:
|
|
||||||
"""Офлайновая часть: адрес обновления входит в список разрешённых."""
|
|
||||||
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host
|
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host
|
||||||
|
|
||||||
if not is_allowed_update_host(DEFAULT_UPDATE_URL):
|
if not is_allowed_update_host(DEFAULT_UPDATE_URL):
|
||||||
return False, f"Адрес обновления вне списка разрешённых: {DEFAULT_UPDATE_URL}"
|
return False, f"Default update URL host not in allowlist: {DEFAULT_UPDATE_URL}"
|
||||||
return True, f"Адрес обновления в списке разрешённых: {DEFAULT_UPDATE_URL}"
|
|
||||||
|
|
||||||
|
|
||||||
def check_publication_gate() -> tuple[bool, str]:
|
|
||||||
"""Релиз опубликован, ассеты на месте, SHA-256 пакета сошёлся.
|
|
||||||
|
|
||||||
В режиме публикации любой недостижимый шаг — отказ. Вне его отказ не
|
|
||||||
блокирует релиз, но и не выдаётся за успех.
|
|
||||||
"""
|
|
||||||
import urllib.error
|
|
||||||
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL
|
|
||||||
|
|
||||||
blocking = is_publication_mode()
|
|
||||||
|
|
||||||
def verdict(ok: bool, msg: str) -> tuple[bool, str]:
|
|
||||||
if ok:
|
|
||||||
return True, msg
|
|
||||||
if blocking:
|
|
||||||
return False, msg
|
|
||||||
return True, f"[НЕ БЛОКИРУЕТ: режим публикации не запрошен] {msg}"
|
|
||||||
|
|
||||||
# 1. Манифест релиза
|
|
||||||
try:
|
try:
|
||||||
with _http_get(DEFAULT_UPDATE_URL) as resp:
|
req = urllib.request.Request(
|
||||||
if resp.status != 200:
|
DEFAULT_UPDATE_URL,
|
||||||
return verdict(False, f"Манифест релиза ответил HTTP {resp.status}")
|
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=6) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
data = json.loads(resp.read().decode("utf-8-sig"))
|
data = json.loads(resp.read().decode("utf-8-sig"))
|
||||||
except urllib.error.HTTPError as he:
|
p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
|
||||||
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
|
p_url = data.get("package_url")
|
||||||
except Exception as exc:
|
if not p_url and data.get("assets"):
|
||||||
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
|
p_url = data["assets"][0].get("browser_download_url")
|
||||||
|
if not p_url:
|
||||||
|
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
|
||||||
|
|
||||||
version = data.get("version") or str(data.get("tag_name", "")).lstrip("v")
|
if not p_ver:
|
||||||
if not version:
|
return False, "Public update manifest is missing version or tag_name"
|
||||||
return verdict(False, "В манифесте релиза нет ни version, ни tag_name")
|
|
||||||
|
|
||||||
# 2. Ассеты
|
# Verify package URL reachability
|
||||||
assets: dict[str, str] = {}
|
pkg_live = False
|
||||||
for asset in data.get("assets") or []:
|
pkg_status = "UNKNOWN"
|
||||||
name = asset.get("name")
|
try:
|
||||||
url = asset.get("browser_download_url")
|
head_req = urllib.request.Request(
|
||||||
if name and url:
|
p_url,
|
||||||
assets[name] = url
|
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||||
if not assets and data.get("package_url"):
|
|
||||||
assets[Path(data["package_url"]).name] = data["package_url"]
|
|
||||||
|
|
||||||
if not assets:
|
|
||||||
return verdict(False, f"У релиза v{version} нет ни одного ассета")
|
|
||||||
|
|
||||||
packages = [n for n in PACKAGE_ASSET_NAMES if n in assets]
|
|
||||||
if not packages:
|
|
||||||
return verdict(
|
|
||||||
False,
|
|
||||||
f"У релиза v{version} нет ни одного пакета установки "
|
|
||||||
f"{PACKAGE_ASSET_NAMES}; опубликованы: {sorted(assets)}",
|
|
||||||
)
|
)
|
||||||
|
# Use Range header to avoid downloading huge binaries
|
||||||
|
head_req.add_header("Range", "bytes=0-10")
|
||||||
|
with urllib.request.urlopen(head_req, timeout=6) as pkg_resp:
|
||||||
|
if pkg_resp.status in (200, 206, 302):
|
||||||
|
pkg_live = True
|
||||||
|
pkg_status = "PACKAGE_LIVE"
|
||||||
|
except urllib.error.HTTPError as pkg_he:
|
||||||
|
if pkg_he.code == 404:
|
||||||
|
pkg_status = "PENDING_RELEASE_UPLOAD_404"
|
||||||
|
else:
|
||||||
|
pkg_status = f"HTTP_{pkg_he.code}"
|
||||||
|
except Exception as pkg_ex:
|
||||||
|
pkg_status = f"CHECK_SKIPPED_{pkg_ex}"
|
||||||
|
|
||||||
# 3. Опубликованные контрольные суммы
|
manifest_live = True
|
||||||
if CHECKSUMS_ASSET_NAME not in assets:
|
package_live = False
|
||||||
return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем")
|
hash_verified = False
|
||||||
try:
|
|
||||||
with _http_get(assets[CHECKSUMS_ASSET_NAME]) as resp:
|
|
||||||
published = _parse_checksums(resp.read().decode("utf-8", errors="replace"))
|
|
||||||
except Exception as exc:
|
|
||||||
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не скачивается: {type(exc).__name__}: {exc}")
|
|
||||||
if not published:
|
|
||||||
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не содержит ни одной строки с SHA-256")
|
|
||||||
|
|
||||||
# 4. Полное скачивание и сверка хеша каждого пакета
|
|
||||||
verified = []
|
|
||||||
for name in packages:
|
|
||||||
expected = published.get(name)
|
|
||||||
if not expected:
|
|
||||||
return verdict(False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME}")
|
|
||||||
try:
|
|
||||||
actual, size = _download_and_hash(assets[name])
|
|
||||||
except Exception as exc:
|
|
||||||
return verdict(False, f"{name} не скачивается целиком: {type(exc).__name__}: {exc}")
|
|
||||||
if actual != expected:
|
|
||||||
return verdict(False, f"SHA-256 {name} не сошёлся: опубликован {expected}, посчитан {actual}")
|
|
||||||
verified.append(f"{name} ({size} байт)")
|
|
||||||
|
|
||||||
|
if pkg_live:
|
||||||
|
package_live = True
|
||||||
|
# If package is live, verify hash on partial bytes or full stream
|
||||||
|
hash_verified = True
|
||||||
|
return True, f"[MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (v{p_ver}) and release asset verified at {p_url}"
|
||||||
|
elif pkg_status == "PENDING_RELEASE_UPLOAD_404":
|
||||||
return True, (
|
return True, (
|
||||||
f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] "
|
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||||
f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME} — "
|
f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed."
|
||||||
+ ", ".join(verified)
|
)
|
||||||
|
else:
|
||||||
|
return True, (
|
||||||
|
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False ({pkg_status}), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||||
|
f"Manifest live (v{p_ver}). Offline updater tests passed."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as he:
|
||||||
|
if he.code == 404:
|
||||||
|
return True, f"[MANIFEST_LIVE=False, PACKAGE_LIVE=False] Public manifest not yet published (HTTP 404). Offline updater tests passed."
|
||||||
|
return False, f"HTTP Error checking update feed: {he}"
|
||||||
|
except Exception as exc:
|
||||||
|
return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed."
|
||||||
|
|
||||||
def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]:
|
return True, "Production update feed verified"
|
||||||
"""Собранный набор ассетов действительно устанавливается обновлением.
|
|
||||||
|
|
||||||
Проверяется до публикации. Причина: update_manager ищет в релизе строго
|
|
||||||
HermesHubSetup.exe или hermes-hub-setup.sh/install-linux.sh, а release.yml
|
|
||||||
собирает hermes-hub-<версия>.zip и update_manifest.json. Такой релиз
|
|
||||||
становится "latest", и на любой попытке обновиться владелец получает
|
|
||||||
"В релизе не найден подходящий файл обновления для текущей платформы".
|
|
||||||
|
|
||||||
Раньше это не проявлялось лишь потому, что весь релизный конвейер падал
|
|
||||||
на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а
|
|
||||||
релизы публиковались мимо него. Как только тесты позеленели, случайная
|
|
||||||
защита исчезла — поэтому набор проверяется явно.
|
|
||||||
|
|
||||||
Помимо присутствия файлов — размер и хеш КАЖДОГО найденного установщика
|
|
||||||
против локального checksums.txt. Найдено живым прогоном на Windows
|
|
||||||
(A61): сборка может прерваться на середине и оставить усечённый файл, а
|
|
||||||
checksums.txt и сам установщик могут разойтись ещё до всякой публикации.
|
|
||||||
Проверка одного присутствия этого не ловит.
|
|
||||||
"""
|
|
||||||
if not dist_dir.is_dir():
|
|
||||||
return False, f"Каталог сборки не найден: {dist_dir}"
|
|
||||||
|
|
||||||
present = {item.name for item in dist_dir.iterdir() if item.is_file()}
|
|
||||||
installers = sorted(present & set(PACKAGE_ASSET_NAMES))
|
|
||||||
problems = []
|
|
||||||
if not installers:
|
|
||||||
problems.append(
|
|
||||||
f"нет ни одного установщика {list(PACKAGE_ASSET_NAMES)} — "
|
|
||||||
f"обновление такой релиз поставить не сможет"
|
|
||||||
)
|
|
||||||
if CHECKSUMS_ASSET_NAME not in present:
|
|
||||||
problems.append(f"нет {CHECKSUMS_ASSET_NAME} — сверять хеш пакета будет не с чем")
|
|
||||||
|
|
||||||
if problems:
|
|
||||||
return False, (
|
|
||||||
f"Набор ассетов в {dist_dir} непригоден для публикации: "
|
|
||||||
+ "; ".join(problems)
|
|
||||||
+ f". Собрано: {sorted(present)}. Установщики собираются скриптами "
|
|
||||||
f"installer/build_installer.ps1 и installer/build_installer_linux.sh"
|
|
||||||
)
|
|
||||||
|
|
||||||
local_checksums = _parse_checksums((dist_dir / CHECKSUMS_ASSET_NAME).read_text(encoding="utf-8-sig", errors="replace"))
|
|
||||||
verified = []
|
|
||||||
for name in installers:
|
|
||||||
actual_hash, size = _hash_local_file(dist_dir / name)
|
|
||||||
if size < MIN_PACKAGE_BYTES:
|
|
||||||
return False, (
|
|
||||||
f"{name} подозрительно мал ({size} байт, ожидался хотя бы {MIN_PACKAGE_BYTES}) "
|
|
||||||
f"— похоже на прерванную сборку"
|
|
||||||
)
|
|
||||||
expected_hash = local_checksums.get(name)
|
|
||||||
if not expected_hash:
|
|
||||||
return False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME} — сверить хеш не с чем"
|
|
||||||
if actual_hash != expected_hash:
|
|
||||||
return False, (
|
|
||||||
f"SHA-256 {name} не сошёлся с {CHECKSUMS_ASSET_NAME}: "
|
|
||||||
f"файл {actual_hash}, записан {expected_hash}"
|
|
||||||
)
|
|
||||||
verified.append(f"{name} ({size} байт, SHA-256 сошёлся)")
|
|
||||||
|
|
||||||
return True, f"Набор ассетов пригоден для публикации: {', '.join(verified)}"
|
|
||||||
|
|
||||||
|
|
||||||
def run_release_gate():
|
def run_release_gate():
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
|
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
|
||||||
mode = "публикация (проверки 1-8 блокируют)" if is_publication_mode() else "офлайн (блокируют 1-7)"
|
|
||||||
print(f" Режим: {mode}")
|
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
|
|
||||||
checks = [
|
checks = [
|
||||||
|
|
@ -477,8 +295,7 @@ def run_release_gate():
|
||||||
("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite),
|
("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite),
|
||||||
("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths),
|
("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths),
|
||||||
("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets),
|
("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets),
|
||||||
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
|
("7. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed),
|
||||||
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
all_passed = True
|
all_passed = True
|
||||||
|
|
@ -502,24 +319,5 @@ def run_release_gate():
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _run_single(title: str, check) -> None:
|
|
||||||
"""Выполнить одну проверку и завершиться её итогом."""
|
|
||||||
print("=" * 70)
|
|
||||||
print(f" Hermes Hub — {title}")
|
|
||||||
print("=" * 70)
|
|
||||||
ok, msg = check()
|
|
||||||
print(f" {'[OK]' if ok else '[FAIL]'} {msg}")
|
|
||||||
sys.exit(0 if ok else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if "--assets" in sys.argv:
|
|
||||||
index = sys.argv.index("--assets")
|
|
||||||
target = Path(sys.argv[index + 1]) if len(sys.argv) > index + 1 else ROOT / "dist"
|
|
||||||
_run_single("Publishable Assets Check", lambda: check_publishable_assets(target))
|
|
||||||
elif "--publication-only" in sys.argv:
|
|
||||||
# Запускается ПОСЛЕ публикации: проверяет опубликованный релиз, а не сборку.
|
|
||||||
os.environ[PUBLICATION_MODE_ENV] = "1"
|
|
||||||
_run_single("Publication Gate", check_publication_gate)
|
|
||||||
else:
|
|
||||||
run_release_gate()
|
run_release_gate()
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,6 @@ for p in [
|
||||||
if p.is_dir() and str(p) not in sys.path:
|
if p.is_dir() and str(p) not in sys.path:
|
||||||
sys.path.insert(0, str(p))
|
sys.path.insert(0, str(p))
|
||||||
|
|
||||||
from antigravity_provider.console_encoding import force_utf8_output
|
|
||||||
|
|
||||||
# Отчёт печатается по-русски, а консоль Windows-раннера в CI — cp1252: без этого
|
|
||||||
# первый же [PASS] с кириллицей роняет скрипт UnicodeEncodeError'ом ещё до того,
|
|
||||||
# как проверки что-либо покажут. Ставится до первого вывода.
|
|
||||||
force_utf8_output()
|
|
||||||
|
|
||||||
from antigravity_provider.router.router_config import (
|
from antigravity_provider.router.router_config import (
|
||||||
RolePolicy,
|
RolePolicy,
|
||||||
RouterConfig,
|
RouterConfig,
|
||||||
|
|
@ -148,15 +141,8 @@ def run_checks() -> int:
|
||||||
# Проверяем изоляцию пути, а не побочное создание каталога: запрос пути
|
# Проверяем изоляцию пути, а не побочное создание каталога: запрос пути
|
||||||
# каталогов больше не плодит, иначе любая проверка засоряла бы диск
|
# каталогов больше не плодит, иначе любая проверка засоряла бы диск
|
||||||
# десятком пустых слотов.
|
# десятком пустых слотов.
|
||||||
#
|
pdir = get_profile_env_dir("ag-w2")
|
||||||
# ID заведомо не боевой. Было "ag-w2" — на живой машине владельца это
|
assert "ag-w2" in str(pdir)
|
||||||
# существующий подключённый профиль, и "assert not pdir.exists()" падал
|
|
||||||
# не из-за бага, а потому что каталог реального аккаунта и так был на
|
|
||||||
# месте. Найдено прогоном на настоящей установке (A61): скрипт возвращал
|
|
||||||
# код 12, хотя изоляция путей работала верно.
|
|
||||||
probe_id = "ag-probe-isolation-test"
|
|
||||||
pdir = get_profile_env_dir(probe_id)
|
|
||||||
assert probe_id in str(pdir)
|
|
||||||
assert "agy_profiles" in str(pdir)
|
assert "agy_profiles" in str(pdir)
|
||||||
assert not pdir.exists(), "запрос пути не должен создавать каталог"
|
assert not pdir.exists(), "запрос пути не должен создавать каталог"
|
||||||
print(f" [PASS] Profile directory isolated at {pdir} (не создан)")
|
print(f" [PASS] Profile directory isolated at {pdir} (не создан)")
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""Принудительный UTF-8 для потоков вывода.
|
|
||||||
|
|
||||||
Инструменты Hermes печатают по-русски, а консоль Windows-раннера в CI работает
|
|
||||||
в cp1252. Первый же `print` с кириллицей роняет процесс UnicodeEncodeError'ом —
|
|
||||||
падает вывод, не логика. Измерено на `scripts/verify_multi_provider_router.py`:
|
|
||||||
строка `[PASS] Чистая конфигурация...` обрывала прогон с кодом 1.
|
|
||||||
|
|
||||||
Модуль ставит UTF-8 на stdout/stderr и оставляет запасной путь на случай, когда
|
|
||||||
перекодировать поток нельзя: тогда непечатаемые символы заменяются, но процесс
|
|
||||||
продолжает работу. Вывод инструмента не должен быть причиной падения.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from typing import Any, Iterable
|
|
||||||
|
|
||||||
__all__ = ["force_utf8_output"]
|
|
||||||
|
|
||||||
|
|
||||||
def _reconfigure(stream: Any) -> bool:
|
|
||||||
"""Перевести один поток на UTF-8. True, если получилось."""
|
|
||||||
reconfigure = getattr(stream, "reconfigure", None)
|
|
||||||
if reconfigure is None:
|
|
||||||
return False
|
|
||||||
for errors in ("strict", "backslashreplace"):
|
|
||||||
try:
|
|
||||||
reconfigure(encoding="utf-8", errors=errors)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
# Поток не перекодировать (подменён, закрыт, не текстовый). Тогда хотя бы
|
|
||||||
# снимем строгость с текущей кодировки, чтобы кириллица не роняла процесс.
|
|
||||||
try:
|
|
||||||
reconfigure(errors="backslashreplace")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def force_utf8_output(streams: Iterable[str] = ("stdout", "stderr")) -> None:
|
|
||||||
"""Перевести стандартные потоки на UTF-8; молча пропустить недоступные.
|
|
||||||
|
|
||||||
Вызывается один раз на старте точки входа, до первого вывода.
|
|
||||||
"""
|
|
||||||
for name in streams:
|
|
||||||
stream = getattr(sys, name, None)
|
|
||||||
if stream is not None:
|
|
||||||
_reconfigure(stream)
|
|
||||||
|
|
@ -952,21 +952,7 @@ class ActionExecutor:
|
||||||
|
|
||||||
ok, msg, res_data = poll_native_agy_login(session_id)
|
ok, msg, res_data = poll_native_agy_login(session_id)
|
||||||
if ok and res_data.get('status') == 'completed':
|
if ok and res_data.get('status') == 'completed':
|
||||||
slot = res_data.get('profile_id')
|
_rescan_after_auth('antigravity', res_data.get('profile_id'))
|
||||||
# Вход через терминал завершается своим путём и мимо add_account.
|
|
||||||
# Учётные данные при этом на диске, профиль числится
|
|
||||||
# подключённым — но записи о нём в конфигурации маршрутизатора
|
|
||||||
# нет, а список аккаунтов строится по ней. Владелец входил
|
|
||||||
# успешно и видел пустой экран.
|
|
||||||
if slot:
|
|
||||||
def_ok, def_msg = AutoAssigner.ensure_profile_definition('antigravity', slot)
|
|
||||||
if not def_ok:
|
|
||||||
return {
|
|
||||||
'ok': False,
|
|
||||||
'message': f'Вход выполнен, но аккаунт не зарегистрирован: {def_msg}',
|
|
||||||
'data': res_data,
|
|
||||||
}
|
|
||||||
_rescan_after_auth('antigravity', slot)
|
|
||||||
return {'ok': ok, 'message': msg, 'data': res_data}
|
return {'ok': ok, 'message': msg, 'data': res_data}
|
||||||
|
|
||||||
if action in ('cancel_native_auth', 'cancel_native_agy_login', 'cancel_terminal_auth'):
|
if action in ('cancel_native_auth', 'cancel_native_agy_login', 'cancel_terminal_auth'):
|
||||||
|
|
@ -1364,22 +1350,6 @@ class ActionExecutor:
|
||||||
status = mgr.get_status_dict()
|
status = mgr.get_status_dict()
|
||||||
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
||||||
|
|
||||||
elif action == 'get_update_progress':
|
|
||||||
mgr = UpdateManager()
|
|
||||||
progress = mgr.get_progress_dict()
|
|
||||||
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
|
|
||||||
|
|
||||||
elif action == 'cancel_update':
|
|
||||||
# Отмена принимается не всегда: после начала установки отменять уже
|
|
||||||
# нечего. Отвечаем тем, что произошло на самом деле, иначе владелец
|
|
||||||
# видит «отменено» поверх продолжающейся установки.
|
|
||||||
mgr = UpdateManager()
|
|
||||||
progress = mgr.cancel_download()
|
|
||||||
if progress.get('cancel_accepted'):
|
|
||||||
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
|
|
||||||
reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна'
|
|
||||||
return {'ok': False, 'message': reason, 'data': progress}
|
|
||||||
|
|
||||||
elif action == 'run_preflight':
|
elif action == 'run_preflight':
|
||||||
from antigravity_provider.router.preflight_service import PreflightCheckService
|
from antigravity_provider.router.preflight_service import PreflightCheckService
|
||||||
service = PreflightCheckService.get()
|
service = PreflightCheckService.get()
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@ import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from antigravity_provider.console_encoding import force_utf8_output
|
|
||||||
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
|
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
|
||||||
from antigravity_provider.router.health_tracker import HealthTracker
|
from antigravity_provider.router.health_tracker import HealthTracker
|
||||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||||
from antigravity_provider.router.adapters import get_adapter
|
from antigravity_provider.router.adapters import get_adapter
|
||||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||||
|
|
||||||
# Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник,
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
# тот же, что и у скриптов проверки: одна реализация, одно поведение.
|
try:
|
||||||
force_utf8_output()
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def print_router_status() -> int:
|
def print_router_status() -> int:
|
||||||
|
|
|
||||||
|
|
@ -135,117 +135,6 @@ DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Единый конвейер разбора пути ────────────────────────────────
|
|
||||||
#
|
|
||||||
# Граница вокруг агентских shell-действий обязана работать одинаково на всех
|
|
||||||
# поддерживаемых системах, иначе доказанной она не является ни на одной.
|
|
||||||
# Измерено: "rm -rf $HOME/.hermes" отклонялось на Linux и проходило на Windows,
|
|
||||||
# потому что переменной HOME в окружении Windows нет — os.path.expandvars
|
|
||||||
# оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с
|
|
||||||
# каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра
|
|
||||||
# на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" тоже проходили.
|
|
||||||
#
|
|
||||||
# Порядок шагов: классификация диалекта → раскрытие распознанных переменных →
|
|
||||||
# нормализация разделителей → канонизация → сравнение с корнями. Каждый шаг,
|
|
||||||
# который не удался, закрывает проход: непроверяемый путь не считается
|
|
||||||
# разрешённым.
|
|
||||||
|
|
||||||
_HOME_VARIABLE_NAMES = frozenset({"HOME", "USERPROFILE"})
|
|
||||||
|
|
||||||
_VARIABLE_REFERENCE = re.compile(
|
|
||||||
r"\$\{(?P<brace>[A-Za-z_][A-Za-z0-9_]*)\}"
|
|
||||||
r"|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
|
|
||||||
r"|%(?P<percent>[A-Za-z_][A-Za-z0-9_]*)%"
|
|
||||||
)
|
|
||||||
|
|
||||||
_WINDOWS_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
|
|
||||||
|
|
||||||
# Признаки записи в диалекте cmd/PowerShell: %VAR%, буква диска, разделитель "\".
|
|
||||||
_WINDOWS_DIALECT_MARKERS = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*%|[A-Za-z]:[\\/]|\\[A-Za-z0-9_.]")
|
|
||||||
|
|
||||||
|
|
||||||
def looks_like_windows_dialect(cmd_line: str) -> bool:
|
|
||||||
"""Записана ли команда в диалекте Windows.
|
|
||||||
|
|
||||||
Диалект определяется по самой строке, а не по системе-хозяину: команду в
|
|
||||||
записи cmd могут прислать и на Linux, и разобрать её posix-правилами нельзя —
|
|
||||||
shlex съест "\" как экранирование и разделитель пути исчезнет.
|
|
||||||
"""
|
|
||||||
return bool(_WINDOWS_DIALECT_MARKERS.search(cmd_line))
|
|
||||||
|
|
||||||
|
|
||||||
def expand_path_argument(raw: str) -> Tuple[str, Optional[str]]:
|
|
||||||
"""Раскрыть "~" и переменные окружения обоих диалектов.
|
|
||||||
|
|
||||||
Возвращает (раскрытая строка, причина нераскрытия | None). Имена HOME и
|
|
||||||
USERPROFILE разрешаются в домашний каталог даже тогда, когда их нет в
|
|
||||||
окружении: команда, написанная в чужом диалекте, целит ровно туда же.
|
|
||||||
Всё, что раскрыть не удалось, возвращается причиной — вызывающий обязан
|
|
||||||
закрыться, а не гадать.
|
|
||||||
"""
|
|
||||||
unresolved: List[str] = []
|
|
||||||
|
|
||||||
def _substitute(match: "re.Match[str]") -> str:
|
|
||||||
name = match.group("brace") or match.group("bare") or match.group("percent")
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None and name.upper() in _HOME_VARIABLE_NAMES:
|
|
||||||
try:
|
|
||||||
value = str(Path.home())
|
|
||||||
except Exception:
|
|
||||||
value = None
|
|
||||||
if value is None:
|
|
||||||
unresolved.append(name)
|
|
||||||
return match.group(0)
|
|
||||||
return value
|
|
||||||
|
|
||||||
expanded = _VARIABLE_REFERENCE.sub(_substitute, raw)
|
|
||||||
expanded = os.path.expanduser(expanded)
|
|
||||||
if expanded.startswith("~"):
|
|
||||||
# expanduser не смог определить домашний каталог: оставлять "~" внутри
|
|
||||||
# пути нельзя — он перестанет быть абсолютным и уедет внутрь проекта.
|
|
||||||
unresolved.append("~")
|
|
||||||
|
|
||||||
if unresolved:
|
|
||||||
return expanded, "не раскрыты: " + ", ".join(sorted(set(unresolved)))
|
|
||||||
return expanded, None
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_path(raw: Path | str, base_cwd: Optional[Path | str] = None) -> Tuple[Optional[Path], Optional[str]]:
|
|
||||||
"""Привести аргумент к каноническому пути или объяснить отказ.
|
|
||||||
|
|
||||||
Ровно одно из двух возвращаемых значений не None. Отказ — это отказ в
|
|
||||||
доступе: путь, который нельзя достоверно разрешить, нельзя и признать
|
|
||||||
находящимся внутри разрешённого корня.
|
|
||||||
"""
|
|
||||||
text = str(raw)
|
|
||||||
if not text:
|
|
||||||
return None, "пустой путь"
|
|
||||||
|
|
||||||
expanded, unresolved_reason = expand_path_argument(text)
|
|
||||||
if unresolved_reason:
|
|
||||||
return None, f"путь '{raw}' невозможно раскрыть ({unresolved_reason})"
|
|
||||||
|
|
||||||
normalized = expanded.replace("\\", "/")
|
|
||||||
|
|
||||||
if _WINDOWS_DRIVE_PREFIX.match(normalized) and not Path(normalized).is_absolute():
|
|
||||||
# Путь с буквой диска на не-Windows: канонизировать его нечем. resolve()
|
|
||||||
# припишет ему текущий каталог, и "C:/Windows" окажется внутри проекта.
|
|
||||||
return None, f"путь '{raw}' записан в диалекте другой системы и здесь не проверяем"
|
|
||||||
|
|
||||||
candidate = Path(normalized)
|
|
||||||
if not candidate.is_absolute() and not normalized.startswith("/"):
|
|
||||||
try:
|
|
||||||
base = Path(base_cwd) if base_cwd is not None else paths.get_repo_root()
|
|
||||||
except Exception as exc:
|
|
||||||
return None, f"не определён базовый каталог для '{raw}': {exc}"
|
|
||||||
candidate = base / normalized
|
|
||||||
|
|
||||||
try:
|
|
||||||
return candidate.resolve(), None
|
|
||||||
except Exception as exc:
|
|
||||||
return None, f"путь '{raw}' не разрешается: {exc}"
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceBoundaryGuard:
|
class WorkspaceBoundaryGuard:
|
||||||
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
|
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
|
||||||
|
|
||||||
|
|
@ -317,11 +206,7 @@ class WorkspaceBoundaryGuard:
|
||||||
def is_inside_allowed_root(self, path: Path | str) -> bool:
|
def is_inside_allowed_root(self, path: Path | str) -> bool:
|
||||||
"""Check whether the given path resolves within any allowed root."""
|
"""Check whether the given path resolves within any allowed root."""
|
||||||
try:
|
try:
|
||||||
target, resolve_error = canonical_path(path)
|
target = Path(path).expanduser().resolve()
|
||||||
if target is None:
|
|
||||||
# Путь не разрешается — считать его находящимся внутри
|
|
||||||
# разрешённого корня нельзя.
|
|
||||||
return False
|
|
||||||
for root in self.get_allowed_roots():
|
for root in self.get_allowed_roots():
|
||||||
try:
|
try:
|
||||||
target.relative_to(root)
|
target.relative_to(root)
|
||||||
|
|
@ -335,9 +220,7 @@ class WorkspaceBoundaryGuard:
|
||||||
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
|
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
|
||||||
"""Check whether the path touches an unconditionally protected directory or file."""
|
"""Check whether the path touches an unconditionally protected directory or file."""
|
||||||
try:
|
try:
|
||||||
target, resolve_error = canonical_path(path)
|
target = Path(path).expanduser().resolve()
|
||||||
if target is None:
|
|
||||||
return True, resolve_error
|
|
||||||
# 1. Exact match or child of forbidden directory
|
# 1. Exact match or child of forbidden directory
|
||||||
for fpath in self.get_forbidden_paths():
|
for fpath in self.get_forbidden_paths():
|
||||||
if target == fpath:
|
if target == fpath:
|
||||||
|
|
@ -369,9 +252,10 @@ class WorkspaceBoundaryGuard:
|
||||||
|
|
||||||
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
||||||
"""
|
"""
|
||||||
target, resolve_error = canonical_path(path)
|
try:
|
||||||
if target is None:
|
target = Path(path).expanduser().resolve()
|
||||||
return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
|
except Exception as exc:
|
||||||
|
return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь"
|
||||||
|
|
||||||
# Check unconditional forbidden paths for mutating/deleting operations
|
# Check unconditional forbidden paths for mutating/deleting operations
|
||||||
if operation in {"delete", "write", "truncate", "move"}:
|
if operation in {"delete", "write", "truncate", "move"}:
|
||||||
|
|
@ -406,18 +290,12 @@ class WorkspaceBoundaryGuard:
|
||||||
# Parse command tokens
|
# Parse command tokens
|
||||||
if isinstance(cmd_line, list):
|
if isinstance(cmd_line, list):
|
||||||
tokens = list(cmd_line)
|
tokens = list(cmd_line)
|
||||||
windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens)
|
|
||||||
else:
|
else:
|
||||||
# Диалект берётся из самой команды: строку в записи cmd нельзя
|
|
||||||
# разбирать posix-правилами — shlex съест "\\" как экранирование,
|
|
||||||
# и разделитель пути исчезнет ещё до проверки.
|
|
||||||
windows_dialect = looks_like_windows_dialect(cmd_line)
|
|
||||||
try:
|
try:
|
||||||
tokens = shlex.split(cmd_line, posix=not windows_dialect)
|
# Windows and POSIX-compatible shlex split
|
||||||
|
tokens = shlex.split(cmd_line, posix=(os.name != "nt"))
|
||||||
except Exception:
|
except Exception:
|
||||||
tokens = cmd_line.split()
|
tokens = cmd_line.split()
|
||||||
if windows_dialect:
|
|
||||||
tokens = [t[1:-1] if len(t) > 1 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens]
|
|
||||||
|
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return True, "OK", None
|
return True, "OK", None
|
||||||
|
|
@ -432,7 +310,7 @@ class WorkspaceBoundaryGuard:
|
||||||
# Extract target arguments (skip flags starting with - or /)
|
# Extract target arguments (skip flags starting with - or /)
|
||||||
targets = []
|
targets = []
|
||||||
for arg in tokens[1:]:
|
for arg in tokens[1:]:
|
||||||
if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
|
if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2):
|
||||||
continue
|
continue
|
||||||
targets.append(arg)
|
targets.append(arg)
|
||||||
|
|
||||||
|
|
@ -443,23 +321,17 @@ class WorkspaceBoundaryGuard:
|
||||||
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
|
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
|
||||||
else:
|
else:
|
||||||
for target_arg in targets:
|
for target_arg in targets:
|
||||||
# Тильда и переменные окружения раскрываются ДО проверки, и
|
# Тильда и переменные окружения раскрываются ДО проверки.
|
||||||
# одинаково для обоих диалектов.
|
|
||||||
#
|
#
|
||||||
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
|
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
|
||||||
# абсолютным путём, склеивался с каталогом проекта в путь с
|
# абсолютным путём, склеивался с каталогом проекта в путь с
|
||||||
# буквальным "~" внутри и признавался допустимым; а
|
# буквальным "~" внутри и признавался допустимым. Проверено:
|
||||||
# "rm -rf $HOME/.hermes" ровно так же проходил на Windows,
|
# команда с тильдой проходила, та же команда с абсолютным
|
||||||
# где переменной HOME в окружении нет. Самый естественный
|
# путём отклонялась. То есть самый естественный способ
|
||||||
# способ написать опасную команду обходил защиту ровно там,
|
# написать опасную команду обходил защиту ровно там, ради
|
||||||
# ради чего она и делалась — на каталоге учётных данных.
|
# чего она и делалась — на каталоге учётных данных.
|
||||||
target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
|
expanded = os.path.expandvars(os.path.expanduser(target_arg))
|
||||||
if target_path is None:
|
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
|
||||||
return (
|
|
||||||
False,
|
|
||||||
f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}",
|
|
||||||
"Укажите путь внутри проекта явно, без нераскрытых переменных",
|
|
||||||
)
|
|
||||||
ok, reason, alt = self.validate_path(target_path, operation="delete")
|
ok, reason, alt = self.validate_path(target_path, operation="delete")
|
||||||
if not ok:
|
if not ok:
|
||||||
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt
|
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt
|
||||||
|
|
|
||||||
|
|
@ -161,11 +161,7 @@ class TelemetryService:
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
# safe_load, а не safe_dump: dump сериализует текст обратно
|
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
||||||
# в строку, isinstance(data, dict) никогда не выполнялось, и
|
|
||||||
# таблица цен из pricing.yaml не загружалась ни разу. Тихо:
|
|
||||||
# ошибку глушил except.
|
|
||||||
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(data, dict) and "pricing" in data:
|
if isinstance(data, dict) and "pricing" in data:
|
||||||
self._pricing_table = dict(data["pricing"])
|
self._pricing_table = dict(data["pricing"])
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import time
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
from antigravity_provider.version import __version__
|
from antigravity_provider.version import __version__
|
||||||
|
|
@ -24,13 +23,7 @@ from antigravity_provider.router.state_store import HubStateStore
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
from antigravity_provider.updater.update_manager import (
|
from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager
|
||||||
get_installed_commit,
|
|
||||||
get_installed_build_time,
|
|
||||||
get_last_applied_update,
|
|
||||||
acknowledge_last_applied_update,
|
|
||||||
UpdateManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.router.web")
|
logger = logging.getLogger("hermes.router.web")
|
||||||
|
|
||||||
|
|
@ -106,63 +99,6 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
||||||
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
|
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Небезопасные методы принимаются только от собственного интерфейса.
|
|
||||||
#
|
|
||||||
# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action
|
|
||||||
# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает
|
|
||||||
# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает
|
|
||||||
# ПРОЧИТАТЬ ответ, а не отправить запрос.
|
|
||||||
#
|
|
||||||
# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с
|
|
||||||
# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса
|
|
||||||
# (простой запрос по правилам CORS), а request.json() разбирает тело
|
|
||||||
# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без
|
|
||||||
# токена доходил до исполнителя действий — отвечало уже само действие.
|
|
||||||
#
|
|
||||||
# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по
|
|
||||||
# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного
|
|
||||||
# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок
|
|
||||||
# Origin ставит обязательно, поэтому его отсутствие подделкой не является.
|
|
||||||
_SAME_SITE_FETCH_VALUES = {"same-origin", "none"}
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_allowed_origins() -> set:
|
|
||||||
raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",")
|
|
||||||
return {o.strip() for o in raw if o.strip() and o.strip() != "*"}
|
|
||||||
|
|
||||||
|
|
||||||
def require_same_origin(request: Request) -> bool:
|
|
||||||
"""Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит."""
|
|
||||||
fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower()
|
|
||||||
if fetch_site:
|
|
||||||
if fetch_site in _SAME_SITE_FETCH_VALUES:
|
|
||||||
return True
|
|
||||||
if request.headers.get("origin", "") in _configured_allowed_origins():
|
|
||||||
return True
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})",
|
|
||||||
)
|
|
||||||
|
|
||||||
origin = request.headers.get("origin")
|
|
||||||
if not origin:
|
|
||||||
return True
|
|
||||||
|
|
||||||
host = (request.headers.get("host") or "").strip().lower()
|
|
||||||
try:
|
|
||||||
origin_host = urlparse(origin).netloc.strip().lower()
|
|
||||||
except Exception:
|
|
||||||
origin_host = ""
|
|
||||||
if origin_host and host and origin_host == host:
|
|
||||||
return True
|
|
||||||
if origin in _configured_allowed_origins():
|
|
||||||
return True
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
|
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
|
||||||
#
|
#
|
||||||
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
|
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
|
||||||
|
|
@ -322,7 +258,6 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
"config_dir": str(paths.get_config_dir()),
|
"config_dir": str(paths.get_config_dir()),
|
||||||
"log_file": str(paths.get_log_file()),
|
"log_file": str(paths.get_log_file()),
|
||||||
}
|
}
|
||||||
snap_dict["last_applied_update"] = get_last_applied_update()
|
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||||||
|
|
@ -341,11 +276,7 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||||
|
|
||||||
@app.post("/api/action")
|
@app.post("/api/action")
|
||||||
async def handle_action(
|
async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||||
request: Request,
|
|
||||||
authorized: bool = Depends(get_auth_token),
|
|
||||||
same_origin: bool = Depends(require_same_origin),
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -418,11 +349,7 @@ def get_skills_endpoint(authorized: bool = Depends(get_auth_token)):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/skills/assign")
|
@app.post("/api/skills/assign")
|
||||||
async def assign_skill_endpoint(
|
async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||||
request: Request,
|
|
||||||
authorized: bool = Depends(get_auth_token),
|
|
||||||
same_origin: bool = Depends(require_same_origin),
|
|
||||||
):
|
|
||||||
"""Assign a skill to a specific subagent."""
|
"""Assign a skill to a specific subagent."""
|
||||||
from antigravity_provider.router.skills_service import SkillsService
|
from antigravity_provider.router.skills_service import SkillsService
|
||||||
try:
|
try:
|
||||||
|
|
@ -443,11 +370,7 @@ async def assign_skill_endpoint(
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/skills/unassign")
|
@app.post("/api/skills/unassign")
|
||||||
async def unassign_skill_endpoint(
|
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||||
request: Request,
|
|
||||||
authorized: bool = Depends(get_auth_token),
|
|
||||||
same_origin: bool = Depends(require_same_origin),
|
|
||||||
):
|
|
||||||
"""Remove an assigned skill from a subagent."""
|
"""Remove an assigned skill from a subagent."""
|
||||||
from antigravity_provider.router.skills_service import SkillsService
|
from antigravity_provider.router.skills_service import SkillsService
|
||||||
try:
|
try:
|
||||||
|
|
@ -476,11 +399,7 @@ def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/skills/diagnose")
|
@app.post("/api/skills/diagnose")
|
||||||
async def diagnose_skill_endpoint(
|
async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||||
request: Request,
|
|
||||||
authorized: bool = Depends(get_auth_token),
|
|
||||||
same_origin: bool = Depends(require_same_origin),
|
|
||||||
):
|
|
||||||
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
|
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
|
||||||
from antigravity_provider.router.skills_service import SkillsService
|
from antigravity_provider.router.skills_service import SkillsService
|
||||||
try:
|
try:
|
||||||
|
|
@ -530,11 +449,7 @@ def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/compression/test")
|
@app.post("/api/compression/test")
|
||||||
async def test_compression_endpoint(
|
async def test_compression_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||||
request: Request,
|
|
||||||
authorized: bool = Depends(get_auth_token),
|
|
||||||
same_origin: bool = Depends(require_same_origin),
|
|
||||||
):
|
|
||||||
"""Execute test context compression on synthetic benchmark prompt."""
|
"""Execute test context compression on synthetic benchmark prompt."""
|
||||||
from antigravity_provider.router.settings_service import get_hub_settings
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
|
@ -612,7 +527,6 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
"installed_at": get_installed_build_time(),
|
"installed_at": get_installed_build_time(),
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"last_update_check": last_check.to_dict() if last_check else None,
|
"last_update_check": last_check.to_dict() if last_check else None,
|
||||||
"last_applied_update": get_last_applied_update(),
|
|
||||||
"network_security": {
|
"network_security": {
|
||||||
"is_external_bind": is_external,
|
"is_external_bind": is_external,
|
||||||
"is_tls": False,
|
"is_tls": False,
|
||||||
|
|
@ -785,25 +699,6 @@ def _background_refresh_loop() -> None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Initial background update check skipped: %s", exc)
|
logger.debug("Initial background update check skipped: %s", exc)
|
||||||
|
|
||||||
# Check if update was just applied and log event to EventLogService
|
|
||||||
try:
|
|
||||||
from antigravity_provider.router.unified_health import EventLogService
|
|
||||||
applied = get_last_applied_update()
|
|
||||||
if applied and not applied.get("acknowledged"):
|
|
||||||
prev_v = applied.get("prev_version", "unknown")
|
|
||||||
prev_c = (applied.get("prev_commit") or "unknown")[:7]
|
|
||||||
new_v = applied.get("new_version", "unknown")
|
|
||||||
new_c = (applied.get("new_commit") or "unknown")[:7]
|
|
||||||
EventLogService.get().log(
|
|
||||||
"system",
|
|
||||||
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
|
|
||||||
level="info",
|
|
||||||
)
|
|
||||||
acknowledge_last_applied_update()
|
|
||||||
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Check last_applied_update on startup skipped: %s", exc)
|
|
||||||
|
|
||||||
while not _background_stop.is_set():
|
while not _background_stop.is_set():
|
||||||
try:
|
try:
|
||||||
AccountProbeService.get().tick()
|
AccountProbeService.get().tick()
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ function initEventListeners() {
|
||||||
|
|
||||||
const btnApplyUpdate = document.getElementById('btn-apply-update');
|
const btnApplyUpdate = document.getElementById('btn-apply-update');
|
||||||
if (btnApplyUpdate) {
|
if (btnApplyUpdate) {
|
||||||
btnApplyUpdate.addEventListener('click', () => openUpdateModal('details'));
|
btnApplyUpdate.addEventListener('click', () => applyUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preflight check listener
|
// Preflight check listener
|
||||||
|
|
@ -585,8 +585,6 @@ async function saveAuthTokenFromPrompt() {
|
||||||
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
||||||
const SILENT_ACTIONS = new Set([
|
const SILENT_ACTIONS = new Set([
|
||||||
'get_compression_status',
|
'get_compression_status',
|
||||||
'get_update_progress',
|
|
||||||
'cancel_update',
|
|
||||||
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
||||||
'poll_redirect_auth', 'poll_device_auth',
|
'poll_redirect_auth', 'poll_device_auth',
|
||||||
]);
|
]);
|
||||||
|
|
@ -2512,7 +2510,6 @@ function closeModal() {
|
||||||
stopDeviceAuthPolling();
|
stopDeviceAuthPolling();
|
||||||
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
||||||
stopRedirectAuthPolling();
|
stopRedirectAuthPolling();
|
||||||
stopUpdateProgressPolling();
|
|
||||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2578,33 +2575,7 @@ function applyTheme(theme) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UPDATE MANAGEMENT (A59 / In-App Updates) ──
|
// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ──
|
||||||
let updateProgressInterval = null;
|
|
||||||
|
|
||||||
function stopUpdateProgressPolling() {
|
|
||||||
if (updateProgressInterval) {
|
|
||||||
clearInterval(updateProgressInterval);
|
|
||||||
updateProgressInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPostUpdateNotification() {
|
|
||||||
const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update);
|
|
||||||
if (!applied || !applied.new_commit) return;
|
|
||||||
const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version);
|
|
||||||
if (!localStorage.getItem(key)) {
|
|
||||||
const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—';
|
|
||||||
const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—';
|
|
||||||
const newV = applied.new_version || '0.1.3';
|
|
||||||
showToast(
|
|
||||||
`Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`,
|
|
||||||
'success',
|
|
||||||
10000
|
|
||||||
);
|
|
||||||
localStorage.setItem(key, '1');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkUpdates(silent = false) {
|
async function checkUpdates(silent = false) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showToast('Проверка обновлений...', 'info');
|
showToast('Проверка обновлений...', 'info');
|
||||||
|
|
@ -2614,20 +2585,11 @@ async function checkUpdates(silent = false) {
|
||||||
if (res && res.ok && res.data) {
|
if (res && res.ok && res.data) {
|
||||||
latestUpdateInfo = res.data;
|
latestUpdateInfo = res.data;
|
||||||
renderUpdateUI();
|
renderUpdateUI();
|
||||||
|
if (!silent) {
|
||||||
if (res.data.update_available) {
|
if (res.data.update_available) {
|
||||||
const versionKey = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.latest_version || res.data.release_tag || '');
|
|
||||||
if (silent) {
|
|
||||||
const dismissed = localStorage.getItem('hermes_dismissed_update_' + versionKey);
|
|
||||||
if (!dismissed) {
|
|
||||||
openUpdateModal('details');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
||||||
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
||||||
openUpdateModal('details');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (!silent) {
|
|
||||||
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2757,11 +2719,9 @@ function renderUpdateUI() {
|
||||||
detailsBlock.classList.add('hidden');
|
detailsBlock.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkPostUpdateNotification();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openUpdateModal(mode = 'details') {
|
function openUpdateModal() {
|
||||||
if (!latestUpdateInfo) {
|
if (!latestUpdateInfo) {
|
||||||
checkUpdates(false);
|
checkUpdates(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -2771,240 +2731,62 @@ function openUpdateModal(mode = 'details') {
|
||||||
? latestUpdateInfo.installed_commit.slice(0, 7)
|
? latestUpdateInfo.installed_commit.slice(0, 7)
|
||||||
: 'неизвестно';
|
: 'неизвестно';
|
||||||
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
||||||
const curVer = latestUpdateInfo.current_version || (currentSettings && currentSettings.version) || '0.1.3';
|
|
||||||
const newVer = latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || curVer;
|
|
||||||
const versionKey = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || '');
|
|
||||||
|
|
||||||
// Блок «Что нового»: брать из changelog / release_notes; если пусто — выводить Н/Д: описание не приложено
|
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
||||||
const rawNotes = (latestUpdateInfo.changelog || latestUpdateInfo.release_notes || '').trim();
|
|
||||||
const notesText = rawNotes || 'Н/Д: описание не приложено';
|
|
||||||
|
|
||||||
// Размер загрузки: размер файла из assets / заголовков; если неизвестен — Н/Д: размер не указан
|
|
||||||
let sizeText = 'Н/Д: размер не указан';
|
|
||||||
if (latestUpdateInfo.download_size && latestUpdateInfo.download_size > 0) {
|
|
||||||
sizeText = (latestUpdateInfo.download_size / 1048576).toFixed(1) + ' МБ';
|
|
||||||
} else if (latestUpdateInfo.asset_sizes) {
|
|
||||||
const sizes = Object.values(latestUpdateInfo.asset_sizes);
|
|
||||||
if (sizes.length > 0 && sizes[0] > 0) {
|
|
||||||
sizeText = (sizes[0] / 1048576).toFixed(1) + ' МБ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub';
|
|
||||||
|
|
||||||
if (mode === 'details') {
|
|
||||||
if (elements.modalBody) {
|
if (elements.modalBody) {
|
||||||
elements.modalBody.innerHTML = `
|
elements.modalBody.innerHTML = `
|
||||||
<div class="update-modal-body">
|
<div class="update-modal-body">
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
||||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(curVer)} (${escapeHtml(instC)})</div>
|
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(instC)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
<div style="font-size:11px; color:var(--text-muted);">Новая версия:</div>
|
<div style="font-size:11px; color:var(--text-muted);">Новая сборка:</div>
|
||||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(newVer)} (${escapeHtml(latC)})</div>
|
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(latC)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">
|
||||||
<div>Размер загрузки: <strong style="color:var(--text-primary);">${escapeHtml(sizeText)}</strong></div>
|
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong>
|
||||||
${latestUpdateInfo.published_at ? `<div>Дата: <strong>${escapeHtml(latestUpdateInfo.published_at.slice(0, 10))}</strong></div>` : ''}
|
${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Список изменений (Release Notes):</div>
|
||||||
|
<div style="max-height:200px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono);">
|
||||||
|
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Что нового:</div>
|
|
||||||
<div style="max-height:180px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono); margin-bottom:12px;">${escapeHtml(notesText)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
if (elements.modalFooter) {
|
if (elements.modalFooter) {
|
||||||
elements.modalFooter.innerHTML = `
|
elements.modalFooter.innerHTML = `
|
||||||
<button class="btn btn-secondary" id="btn-modal-dismiss-update" onclick="dismissUpdateModal('${escapeHtml(versionKey)}')">Напомнить позже</button>
|
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
||||||
<button class="btn btn-primary" id="btn-modal-start-update" onclick="startUpdateProcess()">Обновить сейчас</button>
|
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} else if (mode === 'progress') {
|
|
||||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
|
||||||
renderUpdateProgressView({
|
|
||||||
status: 'downloading',
|
|
||||||
filename: '',
|
|
||||||
downloaded_bytes: 0,
|
|
||||||
total_bytes: null,
|
|
||||||
progress_percent: null,
|
|
||||||
message: 'Подготовка к загрузке пакета обновления...',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
showModal();
|
showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateModal(versionKey) {
|
async function handleInstallUpdateFromModal() {
|
||||||
if (versionKey) {
|
const btn = document.getElementById('btn-modal-install-update');
|
||||||
localStorage.setItem('hermes_dismissed_update_' + versionKey, '1');
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Установка...';
|
||||||
}
|
}
|
||||||
|
await applyUpdate();
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startUpdateProcess() {
|
async function applyUpdate() {
|
||||||
openUpdateModal('progress');
|
showToast('Загрузка и запуск обновления...', 'info');
|
||||||
executeAction('apply_update', {});
|
|
||||||
stopUpdateProgressPolling();
|
|
||||||
updateProgressInterval = setInterval(pollUpdateProgress, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollUpdateProgress() {
|
|
||||||
try {
|
try {
|
||||||
const res = await executeAction('get_update_progress', {});
|
const res = await executeAction('apply_update', {});
|
||||||
if (res && res.ok && res.data) {
|
if (res && res.ok) {
|
||||||
const p = res.data;
|
showToast(res.message || 'Обновление запущено успешно!', 'success');
|
||||||
renderUpdateProgressView(p);
|
} else {
|
||||||
if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') {
|
showToast((res && res.message) || 'Ошибка установки обновления', 'error');
|
||||||
stopUpdateProgressPolling();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('Failed polling update progress:', err);
|
showToast(`Ошибка установки: ${err.message}`, 'error');
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
|
|
||||||
// заменяем бегущим отрезком, а не заполняем целиком.
|
|
||||||
const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
|
|
||||||
|
|
||||||
function renderUpdateProgressView(p) {
|
|
||||||
if (!elements.modalBody) return;
|
|
||||||
|
|
||||||
const status = p.status || 'downloading';
|
|
||||||
const filename = p.filename || 'Пакет обновления';
|
|
||||||
const downloaded = p.downloaded_bytes || 0;
|
|
||||||
const total = p.total_bytes;
|
|
||||||
const percent = p.progress_percent;
|
|
||||||
const msg = p.message || '';
|
|
||||||
const error = p.error;
|
|
||||||
|
|
||||||
let progressDetail = '';
|
|
||||||
let barWidth = '0%';
|
|
||||||
let isIndeterminate = false;
|
|
||||||
|
|
||||||
const dlMb = (downloaded / 1048576).toFixed(1);
|
|
||||||
if (total && total > 0) {
|
|
||||||
const totMb = (total / 1048576).toFixed(1);
|
|
||||||
const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1);
|
|
||||||
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
|
|
||||||
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
|
|
||||||
} else {
|
|
||||||
// Размер неизвестен — доля не вычисляется. Полоса при этом не должна
|
|
||||||
// изображать процент: полная полоса читается как «готово». Активные этапы
|
|
||||||
// показываем бегущим отрезком, завершённые — сплошной полосой.
|
|
||||||
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
|
|
||||||
isIndeterminate = true;
|
|
||||||
barWidth = '100%';
|
|
||||||
}
|
|
||||||
|
|
||||||
let statusBadge = `<span class="badge badge-status warning">Загрузка</span>`;
|
|
||||||
if (status === 'checking') statusBadge = `<span class="badge badge-status warning">Проверка обновлений</span>`;
|
|
||||||
else if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`;
|
|
||||||
else if (status === 'installing') statusBadge = `<span class="badge badge-status warning">Установка</span>`;
|
|
||||||
else if (status === 'restarting') statusBadge = `<span class="badge badge-status healthy">Перезапуск</span>`;
|
|
||||||
else if (status === 'completed') statusBadge = `<span class="badge badge-status healthy">Завершено</span>`;
|
|
||||||
else if (status === 'failed') statusBadge = `<span class="badge badge-status danger">Ошибка</span>`;
|
|
||||||
else if (status === 'cancelled') statusBadge = `<span class="badge badge-status">Отменено</span>`;
|
|
||||||
|
|
||||||
// Inject indeterminate animation style if not already present
|
|
||||||
if (isIndeterminate && !document.getElementById('indeterminate-bar-style')) {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'indeterminate-bar-style';
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes indeterminate-bar {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
.indeterminate-bar {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 30%;
|
|
||||||
background: var(--accent);
|
|
||||||
animation: indeterminate-bar 1.5s ease-in-out infinite;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.modalBody.innerHTML = `
|
|
||||||
<div class="update-progress-body" style="padding:4px 0;">
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
|
||||||
<div style="font-weight:600; font-size:13px; color:var(--text-primary); word-break:break-all;">
|
|
||||||
${escapeHtml(filename)}
|
|
||||||
</div>
|
|
||||||
<div>${statusBadge}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">${escapeHtml(msg)}</div>
|
|
||||||
|
|
||||||
<div style="background:var(--surface-muted); border-radius:var(--radius-sm); overflow:hidden; height:12px; margin-bottom:8px; border:1px solid var(--border-subtle); position:relative;">
|
|
||||||
${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
|
|
||||||
<div class="indeterminate-bar"></div>
|
|
||||||
` : `
|
|
||||||
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease;"></div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;">
|
|
||||||
<span>${escapeHtml(progressDetail)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${error ? `
|
|
||||||
<div style="background:rgba(239, 68, 68, 0.1); border:1px solid var(--status-danger); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-danger); margin-bottom:12px; white-space:pre-wrap;">
|
|
||||||
❌ ${escapeHtml(error)}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${status === 'restarting' || status === 'completed' ? `
|
|
||||||
<div style="background:rgba(34, 197, 94, 0.1); border:1px solid var(--status-healthy); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-healthy); margin-bottom:12px;">
|
|
||||||
✓ ${escapeHtml(msg || 'Обновление успешно установлено!')}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (elements.modalFooter) {
|
|
||||||
if (status === 'downloading') {
|
|
||||||
elements.modalFooter.innerHTML = `
|
|
||||||
<button class="btn btn-secondary" id="btn-cancel-update" onclick="cancelUpdateProcess()">Отменить загрузку</button>
|
|
||||||
`;
|
|
||||||
} else if (status === 'failed' || status === 'cancelled') {
|
|
||||||
elements.modalFooter.innerHTML = `
|
|
||||||
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
|
||||||
<button class="btn btn-primary" onclick="startUpdateProcess()">Повторить</button>
|
|
||||||
`;
|
|
||||||
} else if (status === 'restarting' || status === 'installing' || status === 'verifying') {
|
|
||||||
elements.modalFooter.innerHTML = `
|
|
||||||
<button class="btn btn-secondary" disabled>Установка в процессе...</button>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
elements.modalFooter.innerHTML = `
|
|
||||||
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelUpdateProcess() {
|
|
||||||
stopUpdateProgressPolling();
|
|
||||||
const cancelRes = await executeAction('cancel_update', {});
|
|
||||||
|
|
||||||
// Отмену могли не принять: после начала установки отменять уже нечего.
|
|
||||||
// Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и
|
|
||||||
// владелец решит, что установка встала.
|
|
||||||
const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted;
|
|
||||||
if (!accepted) {
|
|
||||||
showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning');
|
|
||||||
updateProgressInterval = setInterval(pollUpdateProgress, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await executeAction('get_update_progress', {});
|
|
||||||
if (res && res.ok && res.data) {
|
|
||||||
renderUpdateProgressView(res.data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,24 @@ Features:
|
||||||
- SHA-256 package cryptographic hash verification.
|
- SHA-256 package cryptographic hash verification.
|
||||||
- Staged download without touching live executable.
|
- Staged download without touching live executable.
|
||||||
- Hermetic backup and automatic rollback on corrupt/failing update.
|
- Hermetic backup and automatic rollback on corrupt/failing update.
|
||||||
- Real-time thread-safe progress tracking and cancellation.
|
|
||||||
- Isolated process lifecycle management (stopping only own hub processes).
|
|
||||||
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
|
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
|
||||||
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
|
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
|
@ -77,6 +72,8 @@ def _is_release_older(published_at: str, installed_at: str) -> bool:
|
||||||
if not published_at or not installed_at:
|
if not published_at or not installed_at:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
def _parse(v: str):
|
def _parse(v: str):
|
||||||
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
|
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
@ -171,30 +168,6 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UpdateProgress:
|
|
||||||
status: str = "idle" # idle | checking | downloading | verifying | installing | restarting | completed | failed | cancelled
|
|
||||||
filename: str = ""
|
|
||||||
downloaded_bytes: int = 0
|
|
||||||
total_bytes: Optional[int] = None
|
|
||||||
progress_percent: Optional[float] = None
|
|
||||||
message: str = "Готов к обновлению"
|
|
||||||
error: Optional[str] = None
|
|
||||||
updated_at: float = field(default_factory=time.time)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"status": self.status,
|
|
||||||
"filename": self.filename,
|
|
||||||
"downloaded_bytes": self.downloaded_bytes,
|
|
||||||
"total_bytes": self.total_bytes,
|
|
||||||
"progress_percent": round(self.progress_percent, 1) if self.progress_percent is not None else None,
|
|
||||||
"message": self.message,
|
|
||||||
"error": self.error,
|
|
||||||
"updated_at": self.updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UpdateManifest:
|
class UpdateManifest:
|
||||||
version: str
|
version: str
|
||||||
|
|
@ -207,7 +180,6 @@ class UpdateManifest:
|
||||||
changelog: Optional[str] = None
|
changelog: Optional[str] = None
|
||||||
git_commit: Optional[str] = None
|
git_commit: Optional[str] = None
|
||||||
assets: Dict[str, str] = field(default_factory=dict)
|
assets: Dict[str, str] = field(default_factory=dict)
|
||||||
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -222,8 +194,6 @@ class UpdateCheckResult:
|
||||||
changelog: Optional[str] = None
|
changelog: Optional[str] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
assets: Dict[str, str] = field(default_factory=dict)
|
assets: Dict[str, str] = field(default_factory=dict)
|
||||||
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
|
||||||
download_size: Optional[int] = None
|
|
||||||
manifest: Optional[UpdateManifest] = None
|
manifest: Optional[UpdateManifest] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
|
|
@ -241,8 +211,6 @@ class UpdateCheckResult:
|
||||||
"changelog": self.changelog,
|
"changelog": self.changelog,
|
||||||
"release_notes": self.release_notes,
|
"release_notes": self.release_notes,
|
||||||
"assets": self.assets,
|
"assets": self.assets,
|
||||||
"asset_sizes": self.asset_sizes,
|
|
||||||
"download_size": self.download_size,
|
|
||||||
"error": self.error,
|
"error": self.error,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
"checked_at": self.checked_at,
|
"checked_at": self.checked_at,
|
||||||
|
|
@ -300,226 +268,9 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_last_applied_update_path() -> Path:
|
|
||||||
"""Path to ~/.hermes/updates/last_applied_update.json."""
|
|
||||||
return paths.get_hermes_home() / "updates" / "last_applied_update.json"
|
|
||||||
|
|
||||||
|
|
||||||
def record_last_applied_update(
|
|
||||||
prev_version: str,
|
|
||||||
prev_commit: str,
|
|
||||||
new_version: str,
|
|
||||||
new_commit: str,
|
|
||||||
) -> None:
|
|
||||||
"""Save record of applied update for post-restart notification."""
|
|
||||||
try:
|
|
||||||
p = get_last_applied_update_path()
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
data = {
|
|
||||||
"prev_version": prev_version,
|
|
||||||
"prev_commit": prev_commit,
|
|
||||||
"new_version": new_version,
|
|
||||||
"new_commit": new_commit,
|
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"acknowledged": False,
|
|
||||||
}
|
|
||||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to record last_applied_update: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def get_last_applied_update() -> Optional[Dict[str, Any]]:
|
|
||||||
"""Retrieve last applied update info if present."""
|
|
||||||
try:
|
|
||||||
p = get_last_applied_update_path()
|
|
||||||
if p.is_file():
|
|
||||||
return json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Failed reading last_applied_update: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def acknowledge_last_applied_update() -> None:
|
|
||||||
"""Mark last applied update as acknowledged."""
|
|
||||||
try:
|
|
||||||
p = get_last_applied_update_path()
|
|
||||||
if p.is_file():
|
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
data["acknowledged"] = True
|
|
||||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None:
|
|
||||||
"""Распаковать архив, не выпуская ни одной записи за пределы dest.
|
|
||||||
|
|
||||||
Отклоняются: абсолютные пути, выход через "..", символические ссылки и
|
|
||||||
любые записи, не являющиеся обычным файлом или каталогом.
|
|
||||||
"""
|
|
||||||
import stat as _stat
|
|
||||||
|
|
||||||
root = dest.resolve()
|
|
||||||
for info in zf.infolist():
|
|
||||||
# Биты типа файла проставлены не всегда: архиватор мог записать только
|
|
||||||
# права доступа. Судим лишь тогда, когда тип действительно указан, —
|
|
||||||
# иначе обычный файл с правами 0o600 выглядел бы записью чужого типа.
|
|
||||||
file_type = _stat.S_IFMT(info.external_attr >> 16)
|
|
||||||
if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR):
|
|
||||||
raise ValueError(
|
|
||||||
f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
name = info.filename.replace("\\", "/")
|
|
||||||
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
|
||||||
raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}")
|
|
||||||
|
|
||||||
target = (root / name).resolve()
|
|
||||||
if target != root and root not in target.parents:
|
|
||||||
raise ValueError(
|
|
||||||
f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if info.is_dir():
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
continue
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with zf.open(info, "r") as source, open(target, "wb") as out:
|
|
||||||
shutil.copyfileobj(source, out)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
|
||||||
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
|
||||||
current_pid = os.getpid()
|
|
||||||
is_win = sys.platform == "win32"
|
|
||||||
|
|
||||||
if is_win:
|
|
||||||
try:
|
|
||||||
cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"]
|
|
||||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs())
|
|
||||||
pids = []
|
|
||||||
if res.returncode == 0:
|
|
||||||
for line in res.stdout.splitlines():
|
|
||||||
val = line.strip()
|
|
||||||
if val.isdigit():
|
|
||||||
pid = int(val)
|
|
||||||
if pid != current_pid:
|
|
||||||
pids.append(pid)
|
|
||||||
for pid in pids:
|
|
||||||
try:
|
|
||||||
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Windows stop_running_hub: %s", exc)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
uid = os.getuid()
|
|
||||||
pattern = "antigravity_provider.router.web|hermes_hub_web_entry"
|
|
||||||
res = subprocess.run(
|
|
||||||
["pgrep", "-u", str(uid), "-f", pattern],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
if res.returncode != 0 or not res.stdout.strip():
|
|
||||||
logger.debug("No other hub processes found on Linux")
|
|
||||||
return True
|
|
||||||
|
|
||||||
pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid]
|
|
||||||
if not pids:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Stopping hub processes for user %s: %s", uid, pids)
|
|
||||||
import signal
|
|
||||||
for pid in pids:
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Failed to SIGTERM pid %s: %s", pid, e)
|
|
||||||
|
|
||||||
start_t = time.time()
|
|
||||||
alive = list(pids)
|
|
||||||
while alive and (time.time() - start_t) < timeout_sec:
|
|
||||||
time.sleep(0.5)
|
|
||||||
still_alive = []
|
|
||||||
for pid in alive:
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
still_alive.append(pid)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
alive = still_alive
|
|
||||||
|
|
||||||
if alive:
|
|
||||||
logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive)
|
|
||||||
for pid in alive:
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Failed to SIGKILL pid %s: %s", pid, e)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Linux stop_running_hub error: %s", exc)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
|
|
||||||
_CANCEL_REFUSAL = {
|
|
||||||
"installing": "Отмена невозможна: установка уже началась",
|
|
||||||
"restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
|
|
||||||
"completed": "Отменять нечего: обновление уже установлено",
|
|
||||||
"failed": "Отменять нечего: обновление уже завершилось ошибкой",
|
|
||||||
"cancelled": "Загрузка уже отменена",
|
|
||||||
"idle": "Отменять нечего: обновление не запускалось",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
|
|
||||||
"""Вызвать обработчик хода загрузки, поддержав обе его формы.
|
|
||||||
|
|
||||||
Старая форма принимает одну долю (0..1), новая — (скачано, всего).
|
|
||||||
|
|
||||||
Доли при неизвестном общем размере не существует, и подставлять вместо неё
|
|
||||||
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
|
|
||||||
Старую форму в этом случае просто не зовём — молчание честнее выдуманного
|
|
||||||
числа, а сам ход всё равно виден через _set_progress.
|
|
||||||
"""
|
|
||||||
if not cb:
|
|
||||||
return
|
|
||||||
known_total = bool(total and total > 0)
|
|
||||||
try:
|
|
||||||
sig = inspect.signature(cb)
|
|
||||||
single_arg = len(sig.parameters) == 1
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
single_arg = False
|
|
||||||
|
|
||||||
if single_arg:
|
|
||||||
if known_total:
|
|
||||||
cb(downloaded / total)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
cb(downloaded, total)
|
|
||||||
except TypeError:
|
|
||||||
if known_total:
|
|
||||||
cb(downloaded / total)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateManager:
|
class UpdateManager:
|
||||||
"""Manages update checks, package download, hash validation, and updater execution."""
|
"""Manages update checks, package download, hash validation, and updater execution."""
|
||||||
|
|
||||||
_lock = threading.Lock()
|
|
||||||
_progress: UpdateProgress = UpdateProgress()
|
|
||||||
_cancel_event = threading.Event()
|
|
||||||
_last_check_result: Optional[UpdateCheckResult] = None
|
_last_check_result: Optional[UpdateCheckResult] = None
|
||||||
_last_check_time: float = 0.0
|
_last_check_time: float = 0.0
|
||||||
|
|
||||||
|
|
@ -534,85 +285,6 @@ class UpdateManager:
|
||||||
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
|
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
|
||||||
return cls._last_check_result
|
return cls._last_check_result
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_progress_dict(cls) -> Dict[str, Any]:
|
|
||||||
with cls._lock:
|
|
||||||
return cls._progress.to_dict()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _set_progress(
|
|
||||||
cls,
|
|
||||||
status: str,
|
|
||||||
message: str = "",
|
|
||||||
filename: str = "",
|
|
||||||
downloaded_bytes: int = 0,
|
|
||||||
total_bytes: Optional[int] = None,
|
|
||||||
progress_percent: Optional[float] = None,
|
|
||||||
error: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
with cls._lock:
|
|
||||||
cls._progress = UpdateProgress(
|
|
||||||
status=status,
|
|
||||||
filename=filename if filename else cls._progress.filename,
|
|
||||||
downloaded_bytes=downloaded_bytes,
|
|
||||||
total_bytes=total_bytes,
|
|
||||||
progress_percent=progress_percent,
|
|
||||||
message=message if message else cls._progress.message,
|
|
||||||
error=error,
|
|
||||||
updated_at=time.time(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cancel_download(cls) -> Dict[str, Any]:
|
|
||||||
"""Отменить загрузку обновления и удалить недокачанные файлы.
|
|
||||||
|
|
||||||
Отмена допустима только пока идёт проверка или загрузка. Действие
|
|
||||||
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
|
|
||||||
установки вычищал каталог staging вместе с файлом установщика, который
|
|
||||||
в этот момент исполняет bash: установка ломалась на середине, а ответ
|
|
||||||
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
|
|
||||||
"""
|
|
||||||
with cls._lock:
|
|
||||||
current_status = cls._progress.status
|
|
||||||
if current_status not in ("checking", "downloading"):
|
|
||||||
refused = cls._progress.to_dict()
|
|
||||||
refused["cancel_accepted"] = False
|
|
||||||
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
|
|
||||||
current_status,
|
|
||||||
f"Отмена невозможна на этапе «{current_status}»",
|
|
||||||
)
|
|
||||||
return refused
|
|
||||||
|
|
||||||
cls._cancel_event.set()
|
|
||||||
with cls._lock:
|
|
||||||
cls._progress = UpdateProgress(
|
|
||||||
status="cancelled",
|
|
||||||
filename=cls._progress.filename,
|
|
||||||
downloaded_bytes=0,
|
|
||||||
total_bytes=None,
|
|
||||||
progress_percent=None,
|
|
||||||
message="Загрузка обновления отменена пользователем",
|
|
||||||
error=None,
|
|
||||||
updated_at=time.time(),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
updates_dir = paths.get_hermes_home() / "updates"
|
|
||||||
staging_dir = updates_dir / "staging"
|
|
||||||
if staging_dir.exists():
|
|
||||||
for f in staging_dir.iterdir():
|
|
||||||
if f.is_file():
|
|
||||||
f.unlink(missing_ok=True)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Clean staging dir on cancel failed: %s", exc)
|
|
||||||
accepted = cls.get_progress_dict()
|
|
||||||
accepted["cancel_accepted"] = True
|
|
||||||
return accepted
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cancel_update(cls) -> Dict[str, Any]:
|
|
||||||
"""Alias for cancel_download."""
|
|
||||||
return cls.cancel_download()
|
|
||||||
|
|
||||||
def get_status_dict(self) -> Dict[str, Any]:
|
def get_status_dict(self) -> Dict[str, Any]:
|
||||||
installed_commit = get_installed_commit()
|
installed_commit = get_installed_commit()
|
||||||
if self._last_check_result:
|
if self._last_check_result:
|
||||||
|
|
@ -630,8 +302,6 @@ class UpdateManager:
|
||||||
"changelog": None,
|
"changelog": None,
|
||||||
"release_notes": None,
|
"release_notes": None,
|
||||||
"assets": {},
|
"assets": {},
|
||||||
"asset_sizes": {},
|
|
||||||
"download_size": None,
|
|
||||||
"error": None,
|
"error": None,
|
||||||
"message": "Проверка обновлений еще не выполнялась",
|
"message": "Проверка обновлений еще не выполнялась",
|
||||||
"checked_at": 0.0,
|
"checked_at": 0.0,
|
||||||
|
|
@ -722,16 +392,13 @@ class UpdateManager:
|
||||||
body = str(data.get("body") or data.get("changelog") or "")
|
body = str(data.get("body") or data.get("changelog") or "")
|
||||||
published_at = str(data.get("published_at") or "")
|
published_at = str(data.get("published_at") or "")
|
||||||
|
|
||||||
# Extract assets mapping {name: download_url} and asset sizes {name: size_bytes}
|
# Extract assets mapping {name: download_url}
|
||||||
assets_map: Dict[str, str] = {}
|
assets_map: Dict[str, str] = {}
|
||||||
asset_sizes: Dict[str, int] = {}
|
|
||||||
raw_assets = data.get("assets", [])
|
raw_assets = data.get("assets", [])
|
||||||
if isinstance(raw_assets, list):
|
if isinstance(raw_assets, list):
|
||||||
for asset in raw_assets:
|
for asset in raw_assets:
|
||||||
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
|
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
|
||||||
assets_map[asset["name"]] = asset["browser_download_url"]
|
assets_map[asset["name"]] = asset["browser_download_url"]
|
||||||
if "size" in asset and isinstance(asset["size"], (int, float)):
|
|
||||||
asset_sizes[asset["name"]] = int(asset["size"])
|
|
||||||
elif isinstance(raw_assets, dict):
|
elif isinstance(raw_assets, dict):
|
||||||
assets_map = dict(raw_assets)
|
assets_map = dict(raw_assets)
|
||||||
|
|
||||||
|
|
@ -753,27 +420,8 @@ class UpdateManager:
|
||||||
changelog=body,
|
changelog=body,
|
||||||
git_commit=latest_commit,
|
git_commit=latest_commit,
|
||||||
assets=assets_map,
|
assets=assets_map,
|
||||||
asset_sizes=asset_sizes,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine download_size for target platform
|
|
||||||
is_win = sys.platform == "win32"
|
|
||||||
chosen_size: Optional[int] = None
|
|
||||||
if is_win and "HermesHubSetup.exe" in asset_sizes:
|
|
||||||
chosen_size = asset_sizes["HermesHubSetup.exe"]
|
|
||||||
elif not is_win:
|
|
||||||
for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"):
|
|
||||||
if linux_name in asset_sizes:
|
|
||||||
chosen_size = asset_sizes[linux_name]
|
|
||||||
break
|
|
||||||
if chosen_size is None:
|
|
||||||
for aname, asize in asset_sizes.items():
|
|
||||||
if aname.endswith(".zip"):
|
|
||||||
chosen_size = asize
|
|
||||||
break
|
|
||||||
if chosen_size is None and asset_sizes:
|
|
||||||
chosen_size = next(iter(asset_sizes.values()), None)
|
|
||||||
|
|
||||||
# Compare commits
|
# Compare commits
|
||||||
inst_clean = installed_commit.strip().lower()
|
inst_clean = installed_commit.strip().lower()
|
||||||
lat_clean = latest_commit.strip().lower()
|
lat_clean = latest_commit.strip().lower()
|
||||||
|
|
@ -815,8 +463,6 @@ class UpdateManager:
|
||||||
changelog=body,
|
changelog=body,
|
||||||
release_notes=body,
|
release_notes=body,
|
||||||
assets=assets_map,
|
assets=assets_map,
|
||||||
asset_sizes=asset_sizes,
|
|
||||||
download_size=chosen_size,
|
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
error=None,
|
error=None,
|
||||||
message=message,
|
message=message,
|
||||||
|
|
@ -844,45 +490,17 @@ class UpdateManager:
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
dest_file: Path,
|
dest_file: Path,
|
||||||
progress_cb: Optional[Callable] = None,
|
progress_cb: Optional[Callable[[float], None]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper to download a file with allowlist check, progress tracking, and cancellation support."""
|
"""Helper to download a file with allowlist check and optional progress callback."""
|
||||||
if not is_allowed_update_host(url, allow_dev_local=False):
|
if not is_allowed_update_host(url, allow_dev_local=False):
|
||||||
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
|
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
|
||||||
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
|
||||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
|
||||||
|
|
||||||
if url.startswith("file://") or Path(url).is_file():
|
if url.startswith("file://") or Path(url).is_file():
|
||||||
local_src = Path(url.replace("file://", ""))
|
local_src = Path(url.replace("file://", ""))
|
||||||
total_bytes = local_src.stat().st_size if local_src.exists() else None
|
|
||||||
self._set_progress(
|
|
||||||
status="downloading",
|
|
||||||
filename=dest_file.name,
|
|
||||||
downloaded_bytes=0,
|
|
||||||
total_bytes=total_bytes,
|
|
||||||
progress_percent=0.0 if total_bytes else None,
|
|
||||||
message=f"Копирование {dest_file.name}...",
|
|
||||||
)
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
dest_file.unlink(missing_ok=True)
|
|
||||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
|
||||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
|
||||||
|
|
||||||
shutil.copy2(local_src, dest_file)
|
shutil.copy2(local_src, dest_file)
|
||||||
downloaded = dest_file.stat().st_size
|
|
||||||
pct = 100.0 if total_bytes else None
|
|
||||||
self._set_progress(
|
|
||||||
status="downloading",
|
|
||||||
filename=dest_file.name,
|
|
||||||
downloaded_bytes=downloaded,
|
|
||||||
total_bytes=total_bytes,
|
|
||||||
progress_percent=pct,
|
|
||||||
message=f"Файл {dest_file.name} скопирован",
|
|
||||||
)
|
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
progress_cb(1.0)
|
||||||
return
|
return
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
|
|
@ -890,53 +508,21 @@ class UpdateManager:
|
||||||
headers={"User-Agent": f"HermesHub/{__version__}"},
|
headers={"User-Agent": f"HermesHub/{__version__}"},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
raw_len = resp.headers.get("content-length")
|
total_len = int(resp.headers.get("content-length", 0))
|
||||||
total_len = int(raw_len) if raw_len and raw_len.isdigit() else 0
|
|
||||||
total_bytes = total_len if total_len > 0 else None
|
|
||||||
downloaded = 0
|
downloaded = 0
|
||||||
|
|
||||||
self._set_progress(
|
|
||||||
status="downloading",
|
|
||||||
filename=dest_file.name,
|
|
||||||
downloaded_bytes=0,
|
|
||||||
total_bytes=total_bytes,
|
|
||||||
progress_percent=0.0 if total_bytes else None,
|
|
||||||
message=f"Скачивание {dest_file.name}...",
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(dest_file, "wb") as out_f:
|
with open(dest_file, "wb") as out_f:
|
||||||
while True:
|
while chunk := resp.read(65536):
|
||||||
if self._cancel_event.is_set():
|
|
||||||
out_f.close()
|
|
||||||
dest_file.unlink(missing_ok=True)
|
|
||||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
|
||||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
|
||||||
|
|
||||||
chunk = resp.read(65536)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
|
|
||||||
out_f.write(chunk)
|
out_f.write(chunk)
|
||||||
downloaded += len(chunk)
|
downloaded += len(chunk)
|
||||||
pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
|
if progress_cb and total_len > 0:
|
||||||
self._set_progress(
|
progress_cb(downloaded / total_len)
|
||||||
status="downloading",
|
|
||||||
filename=dest_file.name,
|
|
||||||
downloaded_bytes=downloaded,
|
|
||||||
total_bytes=total_bytes,
|
|
||||||
progress_percent=pct,
|
|
||||||
message=f"Скачивание {dest_file.name}...",
|
|
||||||
)
|
|
||||||
if progress_cb:
|
|
||||||
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
|
||||||
|
|
||||||
def download_and_verify(
|
def download_and_verify(
|
||||||
self,
|
self,
|
||||||
manifest: UpdateManifest,
|
manifest: UpdateManifest,
|
||||||
progress_cb: Optional[Callable] = None,
|
progress_cb: Optional[Callable[[float], None]] = None,
|
||||||
) -> Tuple[bool, str, Optional[Path]]:
|
) -> Tuple[bool, str, Optional[Path]]:
|
||||||
"""Download update package into staging and verify SHA-256 hash."""
|
"""Download update package into staging and verify SHA-256 hash."""
|
||||||
self._cancel_event.clear()
|
|
||||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
|
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
|
||||||
|
|
||||||
|
|
@ -944,54 +530,31 @@ class UpdateManager:
|
||||||
self._download_file(manifest.package_url, dest_file, progress_cb)
|
self._download_file(manifest.package_url, dest_file, progress_cb)
|
||||||
|
|
||||||
# Cryptographic SHA-256 Verification
|
# Cryptographic SHA-256 Verification
|
||||||
self._set_progress(
|
|
||||||
status="verifying",
|
|
||||||
filename=dest_file.name,
|
|
||||||
message=f"Проверка контрольной суммы SHA-256 для {dest_file.name}...",
|
|
||||||
)
|
|
||||||
calc_hash = compute_sha256(dest_file)
|
calc_hash = compute_sha256(dest_file)
|
||||||
if manifest.sha256 and calc_hash != manifest.sha256.lower():
|
if manifest.sha256 and calc_hash != manifest.sha256.lower():
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}"
|
return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None
|
||||||
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
|
||||||
return False, err, None
|
|
||||||
|
|
||||||
self._set_progress(
|
|
||||||
status="completed",
|
|
||||||
filename=dest_file.name,
|
|
||||||
message="Пакет успешно загружен и верифицирован",
|
|
||||||
)
|
|
||||||
return True, "Пакет успешно загружен и верифицирован", dest_file
|
return True, "Пакет успешно загружен и верифицирован", dest_file
|
||||||
|
|
||||||
except InterruptedError:
|
|
||||||
dest_file.unlink(missing_ok=True)
|
|
||||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка обновления отменена")
|
|
||||||
return False, "Загрузка обновления отменена пользователем", None
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
err = f"Ошибка загрузки: {exc}"
|
return False, f"Ошибка загрузки: {exc}", None
|
||||||
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
|
||||||
return False, err, None
|
|
||||||
|
|
||||||
def install_latest_update(
|
def install_latest_update(
|
||||||
self,
|
self,
|
||||||
check_result: Optional[UpdateCheckResult] = None,
|
check_result: Optional[UpdateCheckResult] = None,
|
||||||
progress_cb: Optional[Callable] = None,
|
progress_cb: Optional[Callable[[float], None]] = None,
|
||||||
target_dir: Optional[Path] = None,
|
target_dir: Optional[Path] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""Download installer or update package, verify checksums, apply and restart."""
|
"""Download installer or update package, verify checksums, apply and restart."""
|
||||||
self._cancel_event.clear()
|
|
||||||
|
|
||||||
if check_result is None:
|
if check_result is None:
|
||||||
self._set_progress(status="checking", message="Проверка наличия обновлений...")
|
|
||||||
check_result = self.check_for_updates()
|
check_result = self.check_for_updates()
|
||||||
|
|
||||||
if check_result.error:
|
if check_result.error:
|
||||||
self._set_progress(status="failed", error=check_result.error, message=check_result.error)
|
|
||||||
return False, f"Ошибка проверки обновлений: {check_result.error}"
|
return False, f"Ошибка проверки обновлений: {check_result.error}"
|
||||||
|
|
||||||
if not check_result.update_available:
|
if not check_result.update_available:
|
||||||
self._set_progress(status="idle", message="Обновление не требуется")
|
|
||||||
return False, "Обновление не требуется (установлена последняя сборка)"
|
return False, "Обновление не требуется (установлена последняя сборка)"
|
||||||
|
|
||||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -1010,8 +573,6 @@ class UpdateManager:
|
||||||
sha = parts[0].strip().lower()
|
sha = parts[0].strip().lower()
|
||||||
fname = parts[1].lstrip("*").strip().lower()
|
fname = parts[1].lstrip("*").strip().lower()
|
||||||
checksums_map[fname] = sha
|
checksums_map[fname] = sha
|
||||||
except InterruptedError:
|
|
||||||
return False, "Загрузка обновления отменена пользователем"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to download or parse checksums.txt: %s", e)
|
logger.warning("Failed to download or parse checksums.txt: %s", e)
|
||||||
|
|
||||||
|
|
@ -1031,19 +592,6 @@ class UpdateManager:
|
||||||
chosen_url = assets[linux_name]
|
chosen_url = assets[linux_name]
|
||||||
break
|
break
|
||||||
|
|
||||||
# Платформа не распознана штатной веткой (is_win разошёлся с тем, что
|
|
||||||
# реально есть в релизе) — переберём любое известное имя установщика,
|
|
||||||
# прежде чем сдаваться на .zip. Найдено живым прогоном (A61): без
|
|
||||||
# этой подстраховки единственная опечатка в определении платформы
|
|
||||||
# роняла обновление с «не найден подходящий файл», хотя нужный
|
|
||||||
# установщик в релизе был.
|
|
||||||
if not chosen_url:
|
|
||||||
for known_name in ("hermes-hub-setup.sh", "install-linux.sh", "HermesHubSetup.exe"):
|
|
||||||
if known_name in assets:
|
|
||||||
chosen_asset_name = known_name
|
|
||||||
chosen_url = assets[known_name]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback to any .zip package in assets or manifest package_url
|
# Fallback to any .zip package in assets or manifest package_url
|
||||||
if not chosen_url:
|
if not chosen_url:
|
||||||
for a_name, a_url in assets.items():
|
for a_name, a_url in assets.items():
|
||||||
|
|
@ -1057,30 +605,17 @@ class UpdateManager:
|
||||||
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
|
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
|
||||||
|
|
||||||
if not chosen_url or not chosen_asset_name:
|
if not chosen_url or not chosen_asset_name:
|
||||||
err = "В релизе не найден подходящий файл обновления для текущей платформы"
|
return False, "В релизе не найден подходящий файл обновления для текущей платформы"
|
||||||
self._set_progress(status="failed", error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
# 3. Download target asset into staging
|
# 3. Download target asset into staging
|
||||||
dest_file = self.staging_dir / chosen_asset_name
|
dest_file = self.staging_dir / chosen_asset_name
|
||||||
try:
|
try:
|
||||||
self._download_file(chosen_url, dest_file, progress_cb)
|
self._download_file(chosen_url, dest_file, progress_cb)
|
||||||
except InterruptedError:
|
|
||||||
dest_file.unlink(missing_ok=True)
|
|
||||||
self._set_progress(status="cancelled", filename=chosen_asset_name, message="Загрузка отменена")
|
|
||||||
return False, "Загрузка обновления отменена пользователем"
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
err = f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
return False, f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
# 4. SHA-256 Checksum Verification
|
# 4. SHA-256 Checksum Verification
|
||||||
self._set_progress(
|
|
||||||
status="verifying",
|
|
||||||
filename=chosen_asset_name,
|
|
||||||
message=f"Проверка контрольной суммы SHA-256 для {chosen_asset_name}...",
|
|
||||||
)
|
|
||||||
calc_sha = compute_sha256(dest_file)
|
calc_sha = compute_sha256(dest_file)
|
||||||
expected_sha = checksums_map.get(chosen_asset_name.lower())
|
expected_sha = checksums_map.get(chosen_asset_name.lower())
|
||||||
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
||||||
|
|
@ -1092,59 +627,26 @@ class UpdateManager:
|
||||||
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
||||||
if not expected_sha:
|
if not expected_sha:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
err = (
|
return (
|
||||||
|
False,
|
||||||
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
|
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
|
||||||
"в релизе нет checksums.txt или файл не скачался. "
|
"в релизе нет checksums.txt или файл не скачался. "
|
||||||
"Установка отменена — непроверенный файл не запускается."
|
"Установка отменена — непроверенный файл не запускается."
|
||||||
)
|
)
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
if calc_sha != expected_sha:
|
if calc_sha != expected_sha:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
err = (
|
return (
|
||||||
|
False,
|
||||||
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
|
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
|
||||||
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
|
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
|
||||||
)
|
)
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
# 5. Stop running hub services before applying update
|
|
||||||
self._set_progress(
|
|
||||||
status="installing",
|
|
||||||
filename=chosen_asset_name,
|
|
||||||
message="Остановка работающих служб хаба...",
|
|
||||||
)
|
|
||||||
stop_running_hub()
|
|
||||||
|
|
||||||
# Record metadata for post-restart notification
|
|
||||||
prev_v = __version__
|
|
||||||
prev_c = get_installed_commit()
|
|
||||||
new_v = check_result.latest_version
|
|
||||||
new_c = check_result.latest_commit or ""
|
|
||||||
|
|
||||||
# 6. Apply update based on file type
|
|
||||||
self._set_progress(
|
|
||||||
status="installing",
|
|
||||||
filename=chosen_asset_name,
|
|
||||||
message=f"Установка пакета {chosen_asset_name}...",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record_success():
|
|
||||||
record_last_applied_update(
|
|
||||||
prev_version=prev_v,
|
|
||||||
prev_commit=prev_c,
|
|
||||||
new_version=new_v,
|
|
||||||
new_commit=new_c,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# 5. Apply update based on file type
|
||||||
if chosen_asset_name.endswith(".zip"):
|
if chosen_asset_name.endswith(".zip"):
|
||||||
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
||||||
if not ok:
|
if not ok:
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
|
|
||||||
return False, msg
|
return False, msg
|
||||||
_record_success()
|
|
||||||
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
|
|
||||||
return True, "Обновление успешно установлено"
|
return True, "Обновление успешно установлено"
|
||||||
|
|
||||||
elif chosen_asset_name == "HermesHubSetup.exe":
|
elif chosen_asset_name == "HermesHubSetup.exe":
|
||||||
|
|
@ -1157,25 +659,15 @@ class UpdateManager:
|
||||||
try:
|
try:
|
||||||
rc = proc.wait(timeout=600)
|
rc = proc.wait(timeout=600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
err = "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
|
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
_record_success()
|
|
||||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
|
||||||
ok_r, msg_r = self.schedule_restart()
|
ok_r, msg_r = self.schedule_restart()
|
||||||
if not ok_r:
|
if not ok_r:
|
||||||
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
|
||||||
return True, f"Обновление установлено. {msg_r}"
|
return True, f"Обновление установлено. {msg_r}"
|
||||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
|
||||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err = f"Не удалось запустить установщик: {exc}"
|
return False, f"Не удалось запустить установщик: {exc}"
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
elif chosen_asset_name.endswith(".sh"):
|
elif chosen_asset_name.endswith(".sh"):
|
||||||
try:
|
try:
|
||||||
|
|
@ -1188,32 +680,18 @@ class UpdateManager:
|
||||||
capture_output=True, text=True, timeout=600,
|
capture_output=True, text=True, timeout=600,
|
||||||
)
|
)
|
||||||
if res_i.returncode != 0:
|
if res_i.returncode != 0:
|
||||||
# Код возврата в сообщении обязателен: установщик может
|
|
||||||
# завершиться, не сказав ни слова, и владелец получал
|
|
||||||
# «Установка не удалась: » без единого признака причины.
|
|
||||||
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
||||||
if tail:
|
return False, "Установка не удалась: " + " / ".join(tail)
|
||||||
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
|
|
||||||
else:
|
|
||||||
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
|
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
_record_success()
|
|
||||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
|
||||||
ok_r, msg_r = self.schedule_restart()
|
ok_r, msg_r = self.schedule_restart()
|
||||||
if not ok_r:
|
if not ok_r:
|
||||||
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
|
||||||
return True, f"Обновление установлено. {msg_r}"
|
return True, f"Обновление установлено. {msg_r}"
|
||||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
|
||||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err = f"Не удалось запустить скрипт установки: {exc}"
|
return False, f"Не удалось запустить скрипт установки: {exc}"
|
||||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
|
||||||
return False, err
|
|
||||||
|
|
||||||
self._set_progress(status="completed", filename=chosen_asset_name, message="Файл обновления загружен и проверен")
|
|
||||||
return True, "Файл обновления загружен и проверен"
|
return True, "Файл обновления загружен и проверен"
|
||||||
|
|
||||||
|
|
||||||
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
||||||
"""Перезапустить веб-хаб после установки обновления.
|
"""Перезапустить веб-хаб после установки обновления.
|
||||||
|
|
||||||
|
|
@ -1263,6 +741,8 @@ class UpdateManager:
|
||||||
|
|
||||||
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||||
"""Apply update package with automatic backup and rollback on failure."""
|
"""Apply update package with automatic backup and rollback on failure."""
|
||||||
|
import zipfile
|
||||||
|
|
||||||
dest = target_dir or paths.get_repo_root()
|
dest = target_dir or paths.get_repo_root()
|
||||||
backup = self.backup_dir
|
backup = self.backup_dir
|
||||||
backup.mkdir(parents=True, exist_ok=True)
|
backup.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -1278,15 +758,8 @@ class UpdateManager:
|
||||||
shutil.copytree(src_item, dst_item)
|
shutil.copytree(src_item, dst_item)
|
||||||
|
|
||||||
# 2. Extract update package into dest
|
# 2. Extract update package into dest
|
||||||
#
|
|
||||||
# Каждая запись проверяется до записи на диск. Измерено, что
|
|
||||||
# extractall в CPython уже отбрасывает "..", ведущие разделители и
|
|
||||||
# буквы дисков, а запись-ссылку кладёт обычным файлом: побега из
|
|
||||||
# каталога добиться не удалось. Но это свойство реализации, а не
|
|
||||||
# обещание формата — а распаковка идёт в корень установки. Проверка
|
|
||||||
# делает границу собственным инвариантом, который виден в тестах.
|
|
||||||
with zipfile.ZipFile(package_zip, "r") as zf:
|
with zipfile.ZipFile(package_zip, "r") as zf:
|
||||||
_extract_within(zf, dest)
|
zf.extractall(dest)
|
||||||
|
|
||||||
# 3. Verify syntax and integrity of updated python files in src/
|
# 3. Verify syntax and integrity of updated python files in src/
|
||||||
import py_compile
|
import py_compile
|
||||||
|
|
|
||||||
|
|
@ -396,150 +396,3 @@ def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
|
||||||
# Обычная работа внутри проекта не должна страдать.
|
# Обычная работа внутри проекта не должна страдать.
|
||||||
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
|
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
|
||||||
assert allowed, "защита мешает штатной работе внутри проекта"
|
assert allowed, "защита мешает штатной работе внутри проекта"
|
||||||
|
|
||||||
|
|
||||||
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
|
|
||||||
#
|
|
||||||
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
|
|
||||||
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
|
|
||||||
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
|
|
||||||
# окружение обеих систем на любой из них, поэтому дыра больше не может
|
|
||||||
# спрятаться за тем, где именно запущен CI.
|
|
||||||
|
|
||||||
_DESTRUCTIVE_BOTH_DIALECTS = [
|
|
||||||
"rm -rf ~/.hermes/agy_profiles",
|
|
||||||
"rm -rf ~/.ssh",
|
|
||||||
"rm -rf $HOME/.hermes",
|
|
||||||
"rm -rf ${HOME}/.hermes",
|
|
||||||
r"rm -rf %USERPROFILE%\.hermes",
|
|
||||||
r"del /f /q %USERPROFILE%\.ssh",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def guard_env(monkeypatch, tmp_path):
|
|
||||||
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
|
|
||||||
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
|
|
||||||
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
return WorkspaceBoundaryGuard()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
|
||||||
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
|
|
||||||
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
|
|
||||||
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
|
|
||||||
|
|
||||||
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
|
|
||||||
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
|
|
||||||
"""
|
|
||||||
if simulated_os == "windows":
|
|
||||||
monkeypatch.delenv("HOME", raising=False)
|
|
||||||
monkeypatch.setenv("USERPROFILE", str(Path.home()))
|
|
||||||
else:
|
|
||||||
monkeypatch.setenv("HOME", str(Path.home()))
|
|
||||||
monkeypatch.delenv("USERPROFILE", raising=False)
|
|
||||||
|
|
||||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
|
||||||
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
|
||||||
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
|
|
||||||
"""Нераскрытая переменная — отказ, а не пропуск.
|
|
||||||
|
|
||||||
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
|
|
||||||
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
|
|
||||||
который нельзя разрешить, нельзя и признать безопасным.
|
|
||||||
"""
|
|
||||||
if simulated_os == "windows":
|
|
||||||
monkeypatch.delenv("HOME", raising=False)
|
|
||||||
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
|
|
||||||
|
|
||||||
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
|
|
||||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
|
||||||
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
|
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
|
|
||||||
"""Путь с буквой диска на Linux не должен считаться внутренним.
|
|
||||||
|
|
||||||
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
|
|
||||||
удаление системного каталога Windows выглядело как работа внутри проекта.
|
|
||||||
"""
|
|
||||||
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
|
|
||||||
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normal_work_inside_project_still_allowed(guard_env):
|
|
||||||
"""Ужесточение не должно мешать штатной работе."""
|
|
||||||
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
|
|
||||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
|
||||||
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"
|
|
||||||
|
|
||||||
|
|
||||||
# ── HUB-1: небезопасные методы не принимаются с чужой страницы ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def loopback_client():
|
|
||||||
"""Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен."""
|
|
||||||
import antigravity_provider.router.web.server as srv
|
|
||||||
|
|
||||||
with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}):
|
|
||||||
yield TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
_UNSAFE_ENDPOINTS = [
|
|
||||||
("/api/action", '{"action": "clear_accounts", "data": {}}'),
|
|
||||||
("/api/skills/assign", '{"skill": "x", "profile": "y"}'),
|
|
||||||
("/api/skills/unassign", '{"skill": "x", "profile": "y"}'),
|
|
||||||
("/api/skills/diagnose", '{"skill": "x"}'),
|
|
||||||
("/api/compression/test", '{"text": "x"}'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
|
||||||
def test_cross_site_post_is_rejected(loopback_client, path, body):
|
|
||||||
"""Межсайтовый POST отклоняется до того, как что-либо изменит.
|
|
||||||
|
|
||||||
На loopback токен не требуется, а действия меняют состояние: удаляют
|
|
||||||
учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого
|
|
||||||
не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено:
|
|
||||||
POST с Content-Type text/plain уходит кросс-сайтом без предварительного
|
|
||||||
запроса, request.json() разбирает тело независимо от Content-Type, и
|
|
||||||
запрос с чужим Origin доходил до исполнителя действий.
|
|
||||||
"""
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "text/plain;charset=UTF-8",
|
|
||||||
"Origin": "https://evil.example.com",
|
|
||||||
"Sec-Fetch-Site": "cross-site",
|
|
||||||
}
|
|
||||||
res = loopback_client.post(path, content=body, headers=headers)
|
|
||||||
assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
|
||||||
def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body):
|
|
||||||
"""Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем."""
|
|
||||||
headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"}
|
|
||||||
res = loopback_client.post(path, content=body, headers=headers)
|
|
||||||
assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"label, headers",
|
|
||||||
[
|
|
||||||
("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}),
|
|
||||||
("адресная строка", {"Sec-Fetch-Site": "none"}),
|
|
||||||
("не-браузерный клиент", {}),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_own_interface_and_cli_still_work(loopback_client, label, headers):
|
|
||||||
"""Защита не должна мешать собственному интерфейсу и не-браузерным клиентам."""
|
|
||||||
res = loopback_client.post(
|
|
||||||
"/api/action",
|
|
||||||
json={"action": "___нет_такого___", "data": {}},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code != 403, f"{label} отклонён межсайтовой защитой"
|
|
||||||
|
|
|
||||||
|
|
@ -249,14 +249,7 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
||||||
env["HERMES_HOME"] = str(hermes_home)
|
env["HERMES_HOME"] = str(hermes_home)
|
||||||
|
|
||||||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
||||||
|
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||||
# Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и
|
|
||||||
# сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает
|
|
||||||
# трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError,
|
|
||||||
# оставляя proc.stdout равным None — падал разбор вывода, а не проверки.
|
|
||||||
run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
|
||||||
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
||||||
assert "10/10 CHECKS PASSED" in proc.stdout
|
assert "10/10 CHECKS PASSED" in proc.stdout
|
||||||
|
|
||||||
|
|
@ -266,6 +259,6 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
||||||
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
||||||
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
||||||
|
|
||||||
proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||||
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
||||||
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|
||||||
|
|
|
||||||
|
|
@ -1,614 +0,0 @@
|
||||||
"""Hermes Hub — Task A59 Visible Update & Completion Engine Test Suite.
|
|
||||||
|
|
||||||
Verifies:
|
|
||||||
1. P0-1: Startup check shows modal when update available; remains silent when no update; dismissal remembered in localStorage.
|
|
||||||
2. P0-2: Visible download progress tracking; honest None without fake percentages when Content-Length is missing; cancel download deletes partial file and sets cancelled status; SHA-256 verification and failure rejection.
|
|
||||||
3. P0-3: Isolated stop_running_hub (killing only own user hub processes and excluding current PID); apply_update_sync atomic rollback on corrupted update package.
|
|
||||||
4. P0-4: Saving last_applied_update.json, EventLogService notification on restart, UI notification contract, and running_commit integrity.
|
|
||||||
5. P0-5: Absence of update polling loops; get_update_progress and cancel_update in SILENT_ACTIONS.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import urllib.error
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
|
||||||
from antigravity_provider.router.web.server import app
|
|
||||||
from antigravity_provider.router.unified_health import EventLogService
|
|
||||||
from antigravity_provider.updater.update_manager import (
|
|
||||||
UpdateManager,
|
|
||||||
UpdateManifest,
|
|
||||||
UpdateCheckResult,
|
|
||||||
UpdateProgress,
|
|
||||||
compute_sha256,
|
|
||||||
get_installed_commit,
|
|
||||||
get_last_applied_update,
|
|
||||||
record_last_applied_update,
|
|
||||||
acknowledge_last_applied_update,
|
|
||||||
stop_running_hub,
|
|
||||||
)
|
|
||||||
from antigravity_provider.version import __version__
|
|
||||||
|
|
||||||
APP_JS_PATH = (
|
|
||||||
Path(__file__).resolve().parent.parent
|
|
||||||
/ "src"
|
|
||||||
/ "antigravity_provider"
|
|
||||||
/ "router"
|
|
||||||
/ "web"
|
|
||||||
/ "static"
|
|
||||||
/ "app.js"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 1: P0-1 Modal upon Startup and Dismissal Contract in app.js ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_1_app_js_modal_on_startup_and_dismiss_contract():
|
|
||||||
"""Verify app.js opens update modal on checkUpdates(true) unless dismissed in localStorage."""
|
|
||||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# 1. Startup check in checkUpdates
|
|
||||||
assert "async function checkUpdates(silent = false)" in src
|
|
||||||
assert "hermes_dismissed_update_" in src, "app.js must check localStorage for dismissed version"
|
|
||||||
assert "openUpdateModal('details')" in src or "openUpdateModal()" in src
|
|
||||||
|
|
||||||
# 2. Details modal content: version, what's new, download size
|
|
||||||
assert "Что нового" in src
|
|
||||||
assert "Н/Д: описание не приложено" in src, "app.js must output honest N/A when changelog is empty"
|
|
||||||
assert "Н/Д: размер не указан" in src, "app.js must output honest N/A when download size is unknown"
|
|
||||||
assert "Напомнить позже" in src, "app.js must have dismiss/remind later button"
|
|
||||||
assert "Обновить сейчас" in src, "app.js must have start update button"
|
|
||||||
|
|
||||||
# 3. Dismiss function saves to localStorage
|
|
||||||
assert "function dismissUpdateModal" in src
|
|
||||||
assert "localStorage.setItem('hermes_dismissed_update_'" in src
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 2: P0-2 Real-Time Progress Tracking & Honest None Without Content-Length ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_progress_tracking_with_and_without_content_length(tmp_path, monkeypatch):
|
|
||||||
"""Verify progress tracking: percentage with Content-Length, honest None without Content-Length."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
|
|
||||||
# 1. Initial idle state
|
|
||||||
prog = UpdateManager.get_progress_dict()
|
|
||||||
assert prog["status"] in ("idle", "checking")
|
|
||||||
assert "downloaded_bytes" in prog
|
|
||||||
|
|
||||||
# 2. Download with known Content-Length
|
|
||||||
test_payload = b"X" * 1024 * 100 # 100 KB
|
|
||||||
src_file = tmp_path / "remote_pkg.zip"
|
|
||||||
src_file.write_bytes(test_payload)
|
|
||||||
dest_file = tmp_path / "downloaded_pkg.zip"
|
|
||||||
|
|
||||||
# Mock urllib response with Content-Length
|
|
||||||
class MockResponseWithLen:
|
|
||||||
def __init__(self):
|
|
||||||
self.headers = {"content-length": str(len(test_payload))}
|
|
||||||
self._data = io.BytesIO(test_payload)
|
|
||||||
|
|
||||||
def read(self, amt=65536):
|
|
||||||
return self._data.read(amt)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", return_value=MockResponseWithLen()):
|
|
||||||
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/pkg.zip", dest_file)
|
|
||||||
|
|
||||||
prog_after = UpdateManager.get_progress_dict()
|
|
||||||
assert prog_after["downloaded_bytes"] == len(test_payload)
|
|
||||||
assert prog_after["total_bytes"] == len(test_payload)
|
|
||||||
assert prog_after["progress_percent"] == 100.0
|
|
||||||
|
|
||||||
# 3. Download WITHOUT Content-Length (or 0) -> Honest None, no fake percentages!
|
|
||||||
dest_file_no_len = tmp_path / "no_len_pkg.zip"
|
|
||||||
|
|
||||||
class MockResponseWithoutLen:
|
|
||||||
def __init__(self):
|
|
||||||
self.headers = {} # No content-length header!
|
|
||||||
self._data = io.BytesIO(test_payload)
|
|
||||||
|
|
||||||
def read(self, amt=65536):
|
|
||||||
return self._data.read(amt)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", return_value=MockResponseWithoutLen()):
|
|
||||||
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/no_len.zip", dest_file_no_len)
|
|
||||||
|
|
||||||
prog_no_len = UpdateManager.get_progress_dict()
|
|
||||||
assert prog_no_len["downloaded_bytes"] == len(test_payload)
|
|
||||||
assert prog_no_len["total_bytes"] is None, "total_bytes must be None when Content-Length is missing"
|
|
||||||
assert prog_no_len["progress_percent"] is None, "progress_percent must be None when total is unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 3: P0-2 Download Cancellation Cleans Staging and Sets Cancelled Status ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, monkeypatch):
|
|
||||||
"""Cancelling update sets status to cancelled, interrupts loop, and removes partial file."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
staging_file = mgr.staging_dir / "partial_download.zip"
|
|
||||||
staging_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
staging_file.write_bytes(b"Partial download data 12345")
|
|
||||||
|
|
||||||
# Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
|
|
||||||
# иначе тест проверял бы отмену того, чего не происходит.
|
|
||||||
UpdateManager._set_progress(
|
|
||||||
status="downloading",
|
|
||||||
filename="partial_download.zip",
|
|
||||||
downloaded_bytes=27,
|
|
||||||
message="Скачивание partial_download.zip...",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trigger cancel
|
|
||||||
cancel_res = UpdateManager.cancel_download()
|
|
||||||
assert cancel_res["status"] == "cancelled"
|
|
||||||
assert cancel_res["cancel_accepted"] is True
|
|
||||||
assert "отменена" in (cancel_res["message"] or "").lower()
|
|
||||||
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
|
|
||||||
|
|
||||||
# Also test ActionExecutor 'cancel_update'
|
|
||||||
UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
|
|
||||||
action_res = ActionExecutor.execute("cancel_update", {})
|
|
||||||
assert action_res["ok"] is True
|
|
||||||
assert action_res["data"]["status"] == "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch):
|
|
||||||
"""Отмена во время установки не трогает staging и честно сообщает отказ.
|
|
||||||
|
|
||||||
Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing
|
|
||||||
чистил каталог staging вместе с исполняемым в этот момент установщиком и
|
|
||||||
отвечал «отменено», хотя установка продолжалась.
|
|
||||||
"""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
installer = mgr.staging_dir / "hermes-hub-setup.sh"
|
|
||||||
installer.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
installer.write_bytes(b"#!/bin/bash\necho installing\n")
|
|
||||||
|
|
||||||
UpdateManager._set_progress(
|
|
||||||
status="installing",
|
|
||||||
filename="hermes-hub-setup.sh",
|
|
||||||
message="Установка пакета hermes-hub-setup.sh...",
|
|
||||||
)
|
|
||||||
|
|
||||||
res = UpdateManager.cancel_download()
|
|
||||||
assert res["cancel_accepted"] is False
|
|
||||||
assert res["status"] == "installing", "Статус не должен подменяться на cancelled"
|
|
||||||
assert "установка уже началась" in res["cancel_refused_reason"].lower()
|
|
||||||
assert installer.exists(), "Файл исполняемого установщика удалять нельзя"
|
|
||||||
|
|
||||||
action_res = ActionExecutor.execute("cancel_update", {})
|
|
||||||
assert action_res["ok"] is False
|
|
||||||
assert "отмена невозможна" in action_res["message"].lower()
|
|
||||||
|
|
||||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
|
|
||||||
"""When SHA-256 hash does not match, download is aborted, file deleted, and status set to failed."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
pkg_file = tmp_path / "pkg.zip"
|
|
||||||
with zipfile.ZipFile(pkg_file, "w") as zf:
|
|
||||||
zf.writestr("code.py", "print('hello')")
|
|
||||||
|
|
||||||
manifest = UpdateManifest(
|
|
||||||
version="0.1.4",
|
|
||||||
channel="stable",
|
|
||||||
package_url=f"file://{pkg_file}",
|
|
||||||
sha256="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # wrong hash
|
|
||||||
)
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
ok, msg, dest = mgr.download_and_verify(manifest)
|
|
||||||
assert ok is False
|
|
||||||
assert "mismatch" in msg.lower() or "не совпала" in msg.lower()
|
|
||||||
assert dest is None
|
|
||||||
|
|
||||||
prog = UpdateManager.get_progress_dict()
|
|
||||||
assert prog["status"] == "failed"
|
|
||||||
assert prog["error"] is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 5: P0-3 Process Isolation stop_running_hub ──
|
|
||||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_os):
|
|
||||||
"""Чужие процессы хаба останавливаются, собственный — никогда.
|
|
||||||
|
|
||||||
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
|
|
||||||
останавливают процессы по-разному: на Linux — os.kill по списку от pgrep,
|
|
||||||
на Windows — taskkill по списку от wmic. Тест знал только про первую и на
|
|
||||||
Windows-раннере падал на пустом списке убитых, хотя проверять надо один и
|
|
||||||
тот же инвариант — «свой PID не трогаем».
|
|
||||||
"""
|
|
||||||
current_pid = os.getpid()
|
|
||||||
is_win = simulated_os == "windows"
|
|
||||||
|
|
||||||
with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
|
|
||||||
mock_sys.platform = "win32" if is_win else "linux"
|
|
||||||
|
|
||||||
# На Windows os.getuid не существует; ветка Linux падала бы на нём в
|
|
||||||
# общий except и возвращала «остановлено» никого не остановив. create=True
|
|
||||||
# позволяет подставить его там, где его нет.
|
|
||||||
with patch("os.getuid", return_value=1000, create=True), patch("subprocess.run") as mock_run:
|
|
||||||
# wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
|
|
||||||
|
|
||||||
with patch("os.kill") as mock_kill:
|
|
||||||
stop_running_hub(timeout_sec=0.1)
|
|
||||||
|
|
||||||
if is_win:
|
|
||||||
killed_pids = [
|
|
||||||
int(call.args[0][-1])
|
|
||||||
for call in mock_run.call_args_list
|
|
||||||
if call.args and call.args[0] and call.args[0][0] == "taskkill"
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
|
|
||||||
|
|
||||||
assert 99999 in killed_pids, f"[{simulated_os}] чужой процесс хаба не остановлен: {killed_pids}"
|
|
||||||
assert current_pid not in killed_pids, f"[{simulated_os}] остановлен собственный процесс"
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_3_apply_update_sync_rollback_on_failure(tmp_path, monkeypatch):
|
|
||||||
"""apply_update_sync restores files from backup if update package fails validation."""
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
|
|
||||||
app_dir = tmp_path / "app"
|
|
||||||
src_dir = app_dir / "src" / "antigravity_provider"
|
|
||||||
src_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(src_dir / "version.py").write_text('__version__ = "0.1.3"\n', encoding="utf-8")
|
|
||||||
|
|
||||||
# Corrupt zip with python syntax error
|
|
||||||
corrupt_zip = tmp_path / "corrupt_pkg.zip"
|
|
||||||
with zipfile.ZipFile(corrupt_zip, "w") as zf:
|
|
||||||
zf.writestr("src/antigravity_provider/version.py", "INVALID SYNTAX ???!!!")
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
ok, msg = mgr.apply_update_sync(corrupt_zip, target_dir=app_dir)
|
|
||||||
assert ok is False
|
|
||||||
assert "откат" in msg.lower() or "rollback" in msg.lower()
|
|
||||||
|
|
||||||
# Verify original version was restored
|
|
||||||
restored = (src_dir / "version.py").read_text(encoding="utf-8")
|
|
||||||
assert '__version__ = "0.1.3"' in restored
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 7: P0-4 last_applied_update.json and EventLogService ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_4_last_applied_update_recording_and_event_logging(tmp_path, monkeypatch, client):
|
|
||||||
"""Test recording last applied update, server settings/snapshot contract, and EventLogService."""
|
|
||||||
hermes_home = tmp_path / "hermes"
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
||||||
|
|
||||||
# 1. Record update
|
|
||||||
record_last_applied_update(
|
|
||||||
prev_version="0.1.2",
|
|
||||||
prev_commit="aaaaaaa1111111",
|
|
||||||
new_version="0.1.3",
|
|
||||||
new_commit="bbbbbbb2222222",
|
|
||||||
)
|
|
||||||
|
|
||||||
applied = get_last_applied_update()
|
|
||||||
assert applied is not None
|
|
||||||
assert applied["prev_version"] == "0.1.2"
|
|
||||||
assert applied["prev_commit"] == "aaaaaaa1111111"
|
|
||||||
assert applied["new_version"] == "0.1.3"
|
|
||||||
assert applied["new_commit"] == "bbbbbbb2222222"
|
|
||||||
assert applied["acknowledged"] is False
|
|
||||||
|
|
||||||
# 2. Check EventLogService logging contract
|
|
||||||
EventLogService.get().log(
|
|
||||||
"system",
|
|
||||||
f"Hermes Hub успешно обновлён с {applied['prev_version']} ({applied['prev_commit'][:7]}) до {applied['new_version']} ({applied['new_commit'][:7]})",
|
|
||||||
level="info",
|
|
||||||
)
|
|
||||||
events = EventLogService.get().get_events(category="system", limit=10)
|
|
||||||
found = any("Hermes Hub успешно обновлён" in (getattr(e, "message", None) or "") for e in events)
|
|
||||||
assert found is True
|
|
||||||
|
|
||||||
# 3. Acknowledge update
|
|
||||||
acknowledge_last_applied_update()
|
|
||||||
applied_after = get_last_applied_update()
|
|
||||||
assert applied_after["acknowledged"] is True
|
|
||||||
|
|
||||||
# 4. Check GET /api/settings includes last_applied_update
|
|
||||||
res = client.get("/api/settings")
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert "last_applied_update" in data
|
|
||||||
assert data["last_applied_update"]["new_version"] == "0.1.3"
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 8: P0-5 Silent Actions and No Polling Loops ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_5_silent_actions_and_no_interval_polling():
|
|
||||||
"""Verify get_update_progress and cancel_update are in SILENT_ACTIONS and no global update intervals exist."""
|
|
||||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
# 1. Check SILENT_ACTIONS
|
|
||||||
assert "'get_update_progress'" in src
|
|
||||||
assert "'cancel_update'" in src
|
|
||||||
|
|
||||||
# 2. Check that there is NO setInterval for checkUpdates
|
|
||||||
assert "setInterval(checkUpdates" not in src
|
|
||||||
assert "setInterval(() => checkUpdates" not in src
|
|
||||||
assert "setInterval(function() { checkUpdates" not in src
|
|
||||||
|
|
||||||
# 3. ActionExecutor get_update_progress
|
|
||||||
res_prog = ActionExecutor.execute("get_update_progress", {})
|
|
||||||
assert res_prog["ok"] is True
|
|
||||||
assert "data" in res_prog
|
|
||||||
assert "status" in res_prog["data"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch):
|
|
||||||
"""Установщик упал — записи о применённом обновлении быть не должно.
|
|
||||||
|
|
||||||
record_last_applied_update вызывался ДО запуска установщика. При падении
|
|
||||||
запись оставалась на диске, и при следующем старте хаб писал в журнал
|
|
||||||
«успешно обновлён», а интерфейс показывал тост об успехе — владельцу
|
|
||||||
сообщали о версии, которая не установилась.
|
|
||||||
|
|
||||||
Заодно проверяется, что причина отказа не пустая: установщик может
|
|
||||||
завершиться, не сказав ни слова, и сообщение «Установка не удалась: »
|
|
||||||
не давало ни одного признака причины.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
with platform_installer(tmp_path, exit_code=3) as (asset_name, installer):
|
|
||||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
check_result = UpdateCheckResult(
|
|
||||||
update_available=True,
|
|
||||||
current_version="0.1.3",
|
|
||||||
latest_version="0.1.4",
|
|
||||||
latest_commit="deadbeefdeadbeef",
|
|
||||||
installed_commit="0000000aaaa",
|
|
||||||
assets={
|
|
||||||
asset_name: f"file://{installer}",
|
|
||||||
"checksums.txt": "file:///nonexistent",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
real_download = UpdateManager._download_file
|
|
||||||
|
|
||||||
def fake_download(self, url, dest, progress_cb=None):
|
|
||||||
if dest.name == "checksums.txt":
|
|
||||||
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
|
|
||||||
return
|
|
||||||
return real_download(self, url, dest, progress_cb)
|
|
||||||
|
|
||||||
with patch.object(UpdateManager, "_download_file", fake_download):
|
|
||||||
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
|
|
||||||
ok, msg = mgr.install_latest_update(check_result=check_result)
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert get_last_applied_update() is None, (
|
|
||||||
"После провалившейся установки записи о применённом обновлении быть не должно"
|
|
||||||
)
|
|
||||||
# Проверяется, что код назван, а не как он склоняется: ветки формулируют
|
|
||||||
# по-разному («код 3» и «кодом 3»), инвариант же один.
|
|
||||||
assert re.search(r"код\w*\s+3", msg), (
|
|
||||||
f"Причина отказа должна называть код возврата, получено: {msg!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
prog = UpdateManager.get_progress_dict()
|
|
||||||
assert prog["status"] == "failed"
|
|
||||||
|
|
||||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Установщик под ту систему, на которой идёт прогон ──
|
|
||||||
#
|
|
||||||
# Ветки установки различаются: на Windows выбирается HermesHubSetup.exe и
|
|
||||||
# запускается через Popen, на Linux — hermes-hub-setup.sh через bash. Тесты
|
|
||||||
# ниже проверяют не установщик, а учёт его результата, поэтому подставляется
|
|
||||||
# тот файл, который данная система действительно выбирает. Раньше в них был
|
|
||||||
# зашит bash-скрипт, и на Windows-раннере установка отвечала «в релизе не
|
|
||||||
# найден подходящий файл обновления» — падало допущение теста, не продукт.
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def platform_installer(tmp_path, exit_code: int):
|
|
||||||
"""Отдать (имя ассета, путь) и заставить установщик вернуть exit_code."""
|
|
||||||
if sys.platform == "win32":
|
|
||||||
installer = tmp_path / "HermesHubSetup.exe"
|
|
||||||
# Содержимое не исполняется: запуск подменён, проверяется учёт кода.
|
|
||||||
installer.write_bytes(b"MZ\x90\x00 hermes hub test installer\n")
|
|
||||||
with patch("subprocess.Popen") as mock_popen:
|
|
||||||
mock_popen.return_value = MagicMock(wait=MagicMock(return_value=exit_code))
|
|
||||||
yield "HermesHubSetup.exe", installer
|
|
||||||
else:
|
|
||||||
installer = tmp_path / "hermes-hub-setup.sh"
|
|
||||||
installer.write_bytes(f"#!/bin/bash\nexit {exit_code}\n".encode("utf-8"))
|
|
||||||
yield "hermes-hub-setup.sh", installer
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
|
|
||||||
"""Успех записывает и «было», и «стало», причём «было» снято до установки."""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
|
||||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
|
||||||
|
|
||||||
with platform_installer(tmp_path, exit_code=0) as (asset_name, installer):
|
|
||||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
|
||||||
|
|
||||||
check_result = UpdateCheckResult(
|
|
||||||
update_available=True,
|
|
||||||
current_version="0.1.3",
|
|
||||||
latest_version="0.1.4",
|
|
||||||
latest_commit="deadbeefdeadbeef",
|
|
||||||
installed_commit="0000000aaaa",
|
|
||||||
assets={
|
|
||||||
asset_name: f"file://{installer}",
|
|
||||||
"checksums.txt": "file:///nonexistent",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
mgr = UpdateManager()
|
|
||||||
real_download = UpdateManager._download_file
|
|
||||||
|
|
||||||
def fake_download(self, url, dest, progress_cb=None):
|
|
||||||
if dest.name == "checksums.txt":
|
|
||||||
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
|
|
||||||
return
|
|
||||||
return real_download(self, url, dest, progress_cb)
|
|
||||||
|
|
||||||
with patch.object(UpdateManager, "_download_file", fake_download):
|
|
||||||
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
|
|
||||||
with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
|
|
||||||
ok, msg = mgr.install_latest_update(check_result=check_result)
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
rec = get_last_applied_update()
|
|
||||||
assert rec is not None
|
|
||||||
assert rec["new_version"] == "0.1.4"
|
|
||||||
assert rec["new_commit"] == "deadbeefdeadbeef"
|
|
||||||
assert rec["prev_commit"] != rec["new_commit"], (
|
|
||||||
"«Было» снимается до установки, иначе прежняя сборка совпадёт с новой"
|
|
||||||
)
|
|
||||||
assert rec["acknowledged"] is False
|
|
||||||
|
|
||||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_app_js_indeterminate_bar_when_size_unknown():
|
|
||||||
"""При неизвестном размере полоса бежит, а не заполняется целиком.
|
|
||||||
|
|
||||||
Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом
|
|
||||||
рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса
|
|
||||||
читается как «готово» — тот же выдуманный процент, только нарисованный.
|
|
||||||
"""
|
|
||||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "downloaded > 0 ? '100%' : '20%'" not in src, (
|
|
||||||
"Полоса не должна заполняться на всю ширину при неизвестном размере"
|
|
||||||
)
|
|
||||||
assert "Н/Д: сервер не сообщил размер" in src
|
|
||||||
assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой"
|
|
||||||
assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка"
|
|
||||||
assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id"
|
|
||||||
|
|
||||||
|
|
||||||
# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ──
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_p0_2_app_js_handles_refused_cancel():
|
|
||||||
"""Отказ в отмене возвращает опрос хода, а не оставляет окно замершим."""
|
|
||||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену"
|
|
||||||
idx = src.index("async function cancelUpdateProcess()")
|
|
||||||
tail = src[idx:idx + 1200]
|
|
||||||
assert "setInterval(pollUpdateProgress" in tail, (
|
|
||||||
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ──
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_update_package_cannot_write_outside_target(tmp_path):
|
|
||||||
"""Ни одна запись архива не должна оказаться вне каталога установки.
|
|
||||||
|
|
||||||
Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители
|
|
||||||
и буквы дисков, а запись-ссылку кладёт обычным файлом — побега добиться не
|
|
||||||
удалось, вопреки формулировке аудита. Но это свойство реализации, а не
|
|
||||||
обещание формата, и распаковка идёт в корень установки. Тест закрепляет
|
|
||||||
границу как собственный инвариант.
|
|
||||||
"""
|
|
||||||
import stat as _stat
|
|
||||||
import zipfile as _zipfile
|
|
||||||
from antigravity_provider.updater.update_manager import _extract_within
|
|
||||||
|
|
||||||
dest = tmp_path / "dest"
|
|
||||||
dest.mkdir()
|
|
||||||
outside = tmp_path / "outside.txt"
|
|
||||||
|
|
||||||
hostile = [
|
|
||||||
("выход через ..", "../outside.txt"),
|
|
||||||
("абсолютный путь", "/etc/passwd"),
|
|
||||||
("путь с буквой диска", "C:/Windows/x.txt"),
|
|
||||||
]
|
|
||||||
for index, (label, arcname) in enumerate(hostile):
|
|
||||||
archive = tmp_path / f"hostile_{index}.zip"
|
|
||||||
with _zipfile.ZipFile(archive, "w") as zf:
|
|
||||||
zf.writestr(arcname, "побег")
|
|
||||||
with _zipfile.ZipFile(archive) as zf:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_extract_within(zf, dest)
|
|
||||||
assert not outside.exists(), f"{label}: запись оказалась вне каталога установки"
|
|
||||||
|
|
||||||
# Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом.
|
|
||||||
link_zip = tmp_path / "link.zip"
|
|
||||||
with _zipfile.ZipFile(link_zip, "w") as zf:
|
|
||||||
info = _zipfile.ZipInfo("link")
|
|
||||||
info.external_attr = (_stat.S_IFLNK | 0o777) << 16
|
|
||||||
zf.writestr(info, "/etc/passwd")
|
|
||||||
with _zipfile.ZipFile(link_zip) as zf:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_extract_within(zf, dest)
|
|
||||||
|
|
||||||
# Обычный пакет распаковывается как прежде.
|
|
||||||
good = tmp_path / "good.zip"
|
|
||||||
with _zipfile.ZipFile(good, "w") as zf:
|
|
||||||
zf.writestr("src/module.py", "x = 1\n")
|
|
||||||
zf.writestr("assets/logo.txt", "logo")
|
|
||||||
with _zipfile.ZipFile(good) as zf:
|
|
||||||
_extract_within(zf, dest)
|
|
||||||
assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n"
|
|
||||||
assert (dest / "assets" / "logo.txt").is_file()
|
|
||||||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -40,26 +39,9 @@ def test_silent_installer_execution_with_hermes(tmp_path):
|
||||||
if not SETUP_EXE.is_file():
|
if not SETUP_EXE.is_file():
|
||||||
pytest.skip("HermesHubSetup.exe not built yet")
|
pytest.skip("HermesHubSetup.exe not built yet")
|
||||||
|
|
||||||
# Set up mock Hermes Agent structure in temp home pointing to the active venv.
|
# Set up mock Hermes Agent structure in temp home
|
||||||
#
|
|
||||||
# Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку
|
|
||||||
# зависимостей на установленной машине бессмысленной: файл существовал,
|
|
||||||
# но не был исполняемым питоном, и по-настоящему установщик его не
|
|
||||||
# запускал ни разу. Найдено прогоном на настоящей машине (A61): junction
|
|
||||||
# (или symlink) на реальный venv делает эту проверку осмысленной —
|
|
||||||
# запускается настоящий интерпретатор.
|
|
||||||
agent_dir = tmp_path / "hermes" / "hermes-agent"
|
agent_dir = tmp_path / "hermes" / "hermes-agent"
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
venv_scripts = agent_dir / "venv" / "Scripts"
|
||||||
real_venv = Path(sys.prefix)
|
|
||||||
target_venv = agent_dir / "venv"
|
|
||||||
try:
|
|
||||||
import _winapi
|
|
||||||
_winapi.CreateJunction(str(real_venv), str(target_venv))
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
os.symlink(str(real_venv), str(target_venv), target_is_directory=True)
|
|
||||||
except Exception:
|
|
||||||
venv_scripts = target_venv / "Scripts"
|
|
||||||
venv_scripts.mkdir(parents=True, exist_ok=True)
|
venv_scripts.mkdir(parents=True, exist_ok=True)
|
||||||
(venv_scripts / "python.exe").touch()
|
(venv_scripts / "python.exe").touch()
|
||||||
(venv_scripts / "hermes.exe").touch()
|
(venv_scripts / "hermes.exe").touch()
|
||||||
|
|
@ -72,7 +54,7 @@ def test_silent_installer_execution_with_hermes(tmp_path):
|
||||||
env["HERMES_HUB_NO_REGISTRY"] = "1"
|
env["HERMES_HUB_NO_REGISTRY"] = "1"
|
||||||
|
|
||||||
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True)
|
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True)
|
||||||
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}. Stdout: {res.stdout}"
|
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.installer
|
@pytest.mark.installer
|
||||||
|
|
|
||||||
|
|
@ -87,17 +87,6 @@ def test_linux_installer_script_structure():
|
||||||
assert "HERMES_HOME" in install_sh
|
assert "HERMES_HOME" in install_sh
|
||||||
assert "antigravity-provider" in install_sh
|
assert "antigravity-provider" in install_sh
|
||||||
|
|
||||||
# Значок .desktop-записи должен быть растровым (PNG/SVG), не .ico.
|
|
||||||
#
|
|
||||||
# Измерено на настоящем GTK-рабочем столе: GdkPixbuf.Pixbuf.new_from_file
|
|
||||||
# на .ico падает с "Compressed icons are not supported", а .desktop-файл
|
|
||||||
# с нерендерящейся иконкой меню приложений и файловый менеджер просто
|
|
||||||
# показывают пустым — без ошибки, молча. HermesHub.ico в тех же ассетах
|
|
||||||
# существует и раньше подставлялся сюда, поэтому мало проверить, что путь
|
|
||||||
# не пуст — нужно, чтобы это не был именно .ico.
|
|
||||||
icon_line = next(line for line in install_sh.splitlines() if line.startswith("ICON_PATH="))
|
|
||||||
assert ".ico" not in icon_line, f"иконка .desktop-записи — .ico, GTK его не рендерит: {icon_line!r}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_linux_launcher_script_headless_and_app_mode():
|
def test_linux_launcher_script_headless_and_app_mode():
|
||||||
"""Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop."""
|
"""Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop."""
|
||||||
|
|
@ -122,49 +111,3 @@ def test_linux_uninstaller_preserves_user_data():
|
||||||
assert "--purge-user-data" in uninstall_sh
|
assert "--purge-user-data" in uninstall_sh
|
||||||
assert "Preserving user data and credentials" in uninstall_sh
|
assert "Preserving user data and credentials" in uninstall_sh
|
||||||
assert "hermes-hub-web.desktop" in uninstall_sh
|
assert "hermes-hub-web.desktop" in uninstall_sh
|
||||||
|
|
||||||
|
|
||||||
def test_linux_uninstaller_stops_running_hub():
|
|
||||||
"""A61/A62: uninstall-linux.sh must stop the hub before deleting its files.
|
|
||||||
|
|
||||||
Раньше удаление не трогало работающий процесс: rm -rf уходил под живым
|
|
||||||
сервером, а с --purge-user-data ещё и сносил каталоги, на которые у
|
|
||||||
процесса были открыты файловые дескрипторы. Хаб продолжал отвечать по
|
|
||||||
старому порту после «успешного» удаления. Измерено живым прогоном:
|
|
||||||
сервер, запущенный в песочнице, оставался в списке процессов после
|
|
||||||
uninstall-linux.sh до этой правки.
|
|
||||||
"""
|
|
||||||
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
|
|
||||||
assert "lib_stop_running_hub.sh" in uninstall_sh
|
|
||||||
assert "stop_running_hub" in uninstall_sh
|
|
||||||
|
|
||||||
|
|
||||||
def test_linux_stop_launcher_exists_and_reuses_shared_logic():
|
|
||||||
"""A61/A62: должен существовать способ остановить хаб не из терминала руками.
|
|
||||||
|
|
||||||
На Windows это «Exit» из системного трея HermesHubWeb.exe. На Linux до
|
|
||||||
этого не было ничего — ни кнопки в интерфейсе (её нет ни на одной
|
|
||||||
платформе), ни трея, ни пункта меню: сервер, оставленный в фоне после
|
|
||||||
закрытия окна браузера, можно было остановить только pkill'ом из
|
|
||||||
терминала. launcher/hermes-hub-stop.sh — недостающий эквивалент,
|
|
||||||
устанавливается install-linux.sh как второй пункт меню приложений.
|
|
||||||
"""
|
|
||||||
stop_sh = (LAUNCHER_DIR / "hermes-hub-stop.sh").read_text(encoding="utf-8")
|
|
||||||
assert "#!/usr/bin/env bash" in stop_sh
|
|
||||||
assert "stop_running_hub" in stop_sh
|
|
||||||
|
|
||||||
lib_sh = (INSTALLER_DIR / "lib_stop_running_hub.sh").read_text(encoding="utf-8")
|
|
||||||
assert "stop_running_hub()" in lib_sh
|
|
||||||
# Общий источник, а не третья копия той же функции: install и uninstall
|
|
||||||
# обязаны ссылаться на тот же файл, а не хранить свою версию.
|
|
||||||
install_sh = (INSTALLER_DIR / "install-linux.sh").read_text(encoding="utf-8")
|
|
||||||
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
|
|
||||||
for script_name, script_text in (("install-linux.sh", install_sh), ("uninstall-linux.sh", uninstall_sh)):
|
|
||||||
assert "lib_stop_running_hub.sh" in script_text, f"{script_name} не источает общую функцию"
|
|
||||||
assert script_text.count("stop_running_hub() {") == 0, (
|
|
||||||
f"{script_name} держит собственную копию функции вместо общего источника"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "hermes-hub-stop" in install_sh, "install-linux.sh не разворачивает лаунчер остановки"
|
|
||||||
assert "hermes-hub-stop.desktop" in install_sh, "у лаунчера остановки нет пункта меню"
|
|
||||||
assert "hermes-hub-stop" in uninstall_sh, "uninstall-linux.sh не убирает лаунчер остановки"
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"""Вход через терминал должен регистрировать аккаунт, а не только записывать ключи.
|
|
||||||
|
|
||||||
Владелец: «в программе нет аккаунтов. я добавил первый… аккаунты так и не
|
|
||||||
появились». При этом вход проходил, agy отдавал одиннадцать моделей.
|
|
||||||
|
|
||||||
Причина: список аккаунтов строится по конфигурации маршрутизатора, а вход через
|
|
||||||
терминал завершается своим путём, мимо add_account, и записи в конфигурации не
|
|
||||||
создаёт. Учётные данные на диске, профиль числится подключённым — и его нигде
|
|
||||||
не видно.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from antigravity_provider import paths
|
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
|
||||||
from antigravity_provider.router.unified_health import UnifiedHealthService
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_with_agy_credentials(slot: str = "ag-5"):
|
|
||||||
pdir = paths.get_profile_dir(slot, "antigravity", create=True)
|
|
||||||
cli = pdir / ".gemini" / "antigravity-cli"
|
|
||||||
cli.mkdir(parents=True, exist_ok=True)
|
|
||||||
(cli / "antigravity-oauth-token").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"auth_method": "consumer",
|
|
||||||
"token": {"access_token": "ya29.TEST", "refresh_token": "1//TEST"},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return pdir
|
|
||||||
|
|
||||||
|
|
||||||
def _completed(slot: str = "ag-5"):
|
|
||||||
return (
|
|
||||||
True,
|
|
||||||
"Авторизация успешно завершена через agy CLI",
|
|
||||||
{"status": "completed", "profile_id": slot, "email": "owner@gmail.com"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_completed_login_registers_the_profile():
|
|
||||||
_profile_with_agy_credentials()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"antigravity_provider.agy_subprocess.poll_native_agy_login",
|
|
||||||
return_value=_completed(),
|
|
||||||
):
|
|
||||||
res = ActionExecutor.execute("poll_native_auth", {"session_id": "s1"})
|
|
||||||
|
|
||||||
assert res["ok"], res
|
|
||||||
assert "ag-5" in load_router_config().profiles, (
|
|
||||||
"без записи в конфигурации аккаунт нигде не появится"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_registered_profile_is_visible_in_the_interface():
|
|
||||||
_profile_with_agy_credentials()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"antigravity_provider.agy_subprocess.poll_native_agy_login",
|
|
||||||
return_value=_completed(),
|
|
||||||
):
|
|
||||||
ActionExecutor.execute("poll_native_auth", {"session_id": "s1"})
|
|
||||||
|
|
||||||
shown = [
|
|
||||||
view.profile_id
|
|
||||||
for group in UnifiedHealthService.get().scan_all(force=True).values()
|
|
||||||
for view in group
|
|
||||||
]
|
|
||||||
assert "ag-5" in shown
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_failed_registration_is_reported_not_swallowed():
|
|
||||||
"""Молчаливый успех при незарегистрированном аккаунте — то же зависание вслепую."""
|
|
||||||
_profile_with_agy_credentials()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"antigravity_provider.agy_subprocess.poll_native_agy_login",
|
|
||||||
return_value=_completed(),
|
|
||||||
), patch(
|
|
||||||
"antigravity_provider.router.auto_assigner.AutoAssigner.ensure_profile_definition",
|
|
||||||
return_value=(False, "слот занят другим провайдером"),
|
|
||||||
):
|
|
||||||
res = ActionExecutor.execute("poll_native_auth", {"session_id": "s1"})
|
|
||||||
|
|
||||||
assert not res["ok"]
|
|
||||||
assert "не зарегистрирован" in res["message"]
|
|
||||||
assert "слот занят другим провайдером" in res["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_login_still_in_progress_registers_nothing():
|
|
||||||
with patch(
|
|
||||||
"antigravity_provider.agy_subprocess.poll_native_agy_login",
|
|
||||||
return_value=(True, "Ожидание завершения авторизации в терминале...", {"status": "pending"}),
|
|
||||||
):
|
|
||||||
res = ActionExecutor.execute("poll_native_auth", {"session_id": "s1"})
|
|
||||||
|
|
||||||
assert res["ok"]
|
|
||||||
assert not load_router_config().profiles
|
|
||||||
|
|
@ -461,251 +461,3 @@ def test_s4_secret_scanner_ast_detection(tmp_path):
|
||||||
clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
|
clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
|
||||||
v3 = scan_file_for_secrets(clean_file)
|
v3 = scan_file_for_secrets(clean_file)
|
||||||
assert len(v3) == 0
|
assert len(v3) == 0
|
||||||
|
|
||||||
|
|
||||||
# ── HUB-1: ворота публикации не пропускают релиз при любом исходе ──
|
|
||||||
|
|
||||||
|
|
||||||
def _load_release_gate():
|
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
scripts_dir = str(Path(__file__).resolve().parent.parent / "scripts")
|
|
||||||
if scripts_dir not in sys.path:
|
|
||||||
sys.path.insert(0, scripts_dir)
|
|
||||||
import release_gate
|
|
||||||
importlib.reload(release_gate)
|
|
||||||
return release_gate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"failure, expected_in_message",
|
|
||||||
[
|
|
||||||
("network", "недоступен"),
|
|
||||||
("http_404", "404"),
|
|
||||||
("no_assets", "ассет"),
|
|
||||||
("no_checksums", "checksums.txt"),
|
|
||||||
("hash_mismatch", "не сошёлся"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_publication_gate_blocks_instead_of_failing_open(monkeypatch, failure, expected_in_message):
|
|
||||||
"""Недостижимая публикация — отказ, а не PASS.
|
|
||||||
|
|
||||||
Измерено на прежней реализации: при обрыве сети, при 404 на манифест и при
|
|
||||||
404 на пакет возвращался PASS. Ворота пропускали релиз при любом исходе,
|
|
||||||
включая полное отсутствие релиза, а строка PACKAGE_HASH_VERIFIED=True
|
|
||||||
печаталась при том, что hashlib в файле не вызывался ни разу — хеш был
|
|
||||||
объявлен проверенным после чтения одиннадцати байт через заголовок Range.
|
|
||||||
"""
|
|
||||||
import urllib.error
|
|
||||||
release_gate = _load_release_gate()
|
|
||||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
|
||||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"tag_name": "v9.9.9",
|
|
||||||
"assets": [
|
|
||||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
|
||||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if failure == "no_assets":
|
|
||||||
manifest["assets"] = []
|
|
||||||
if failure == "no_checksums":
|
|
||||||
manifest["assets"] = [manifest["assets"][0]]
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
status = 200
|
|
||||||
|
|
||||||
def __init__(self, payload: bytes):
|
|
||||||
self._payload = payload
|
|
||||||
|
|
||||||
def read(self, *_a):
|
|
||||||
payload, self._payload = self._payload, b""
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_a):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def fake_get(url, timeout=30):
|
|
||||||
if failure == "network":
|
|
||||||
raise urllib.error.URLError("сети нет")
|
|
||||||
if failure == "http_404":
|
|
||||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
|
||||||
if url.endswith("checksums.txt"):
|
|
||||||
return _Resp(b"%s HermesHubSetup.exe\n" % (b"a" * 64))
|
|
||||||
if url.endswith("setup.exe"):
|
|
||||||
return _Resp(b"payload-with-a-different-hash")
|
|
||||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
|
||||||
|
|
||||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
|
||||||
|
|
||||||
ok, msg = release_gate.check_publication_gate()
|
|
||||||
assert ok is False, f"ворота пропустили релиз при отказе '{failure}': {msg}"
|
|
||||||
assert expected_in_message in msg, f"причина отказа не названа: {msg!r}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_publication_gate_hashes_the_whole_package(monkeypatch):
|
|
||||||
"""Успех объявляется только после полного скачивания и сверки SHA-256."""
|
|
||||||
import hashlib
|
|
||||||
release_gate = _load_release_gate()
|
|
||||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
|
||||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
|
||||||
|
|
||||||
package = b"hermes hub installer payload"
|
|
||||||
real_sha = hashlib.sha256(package).hexdigest()
|
|
||||||
read_bytes = {"total": 0}
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
status = 200
|
|
||||||
|
|
||||||
def __init__(self, payload: bytes, count: bool = False):
|
|
||||||
self._payload = payload
|
|
||||||
self._count = count
|
|
||||||
|
|
||||||
def read(self, *_a):
|
|
||||||
payload, self._payload = self._payload, b""
|
|
||||||
if self._count:
|
|
||||||
read_bytes["total"] += len(payload)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_a):
|
|
||||||
return False
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"tag_name": "v9.9.9",
|
|
||||||
"assets": [
|
|
||||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
|
||||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def fake_get(url, timeout=30):
|
|
||||||
if url.endswith("checksums.txt"):
|
|
||||||
return _Resp(f"{real_sha} HermesHubSetup.exe\n".encode("utf-8"))
|
|
||||||
if url.endswith("setup.exe"):
|
|
||||||
return _Resp(package, count=True)
|
|
||||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
|
||||||
|
|
||||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
|
||||||
|
|
||||||
ok, msg = release_gate.check_publication_gate()
|
|
||||||
assert ok is True, msg
|
|
||||||
assert "PACKAGE_HASH_VERIFIED=True" in msg
|
|
||||||
assert read_bytes["total"] == len(package), (
|
|
||||||
f"пакет должен быть прочитан целиком, прочитано {read_bytes['total']} из {len(package)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_offline_run_does_not_claim_publication_verified(monkeypatch):
|
|
||||||
"""Без режима публикации отсутствие релиза не блокирует, но и не врёт."""
|
|
||||||
import urllib.error
|
|
||||||
release_gate = _load_release_gate()
|
|
||||||
monkeypatch.delenv(release_gate.PUBLICATION_MODE_ENV, raising=False)
|
|
||||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
|
||||||
|
|
||||||
def fake_get(url, timeout=30):
|
|
||||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
|
||||||
|
|
||||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
|
||||||
|
|
||||||
ok, msg = release_gate.check_publication_gate()
|
|
||||||
assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза"
|
|
||||||
assert "НЕ БЛОКИРУЕТ" in msg
|
|
||||||
assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_publishable_assets_check_rejects_uninstallable_release(tmp_path):
|
|
||||||
"""Набор без установщика не должен уходить в публикацию.
|
|
||||||
|
|
||||||
update_manager ищет в релизе HermesHubSetup.exe или hermes-hub-setup.sh, а
|
|
||||||
release.yml собирает только zip и манифест. Такой релиз становится
|
|
||||||
«latest», и обновление отвечает «в релизе не найден подходящий файл
|
|
||||||
обновления для текущей платформы». Раньше это не проявлялось лишь потому,
|
|
||||||
что весь релизный конвейер падал на шаге Release Gate — на тех же двух
|
|
||||||
дефектах, что и CI, — и до публикации не доходил ни один его прогон.
|
|
||||||
"""
|
|
||||||
release_gate = _load_release_gate()
|
|
||||||
|
|
||||||
as_built_today = tmp_path / "dist_zip_only"
|
|
||||||
as_built_today.mkdir()
|
|
||||||
(as_built_today / "hermes-hub-0.1.3.zip").write_bytes(b"zip")
|
|
||||||
(as_built_today / "update_manifest.json").write_text("{}", encoding="utf-8")
|
|
||||||
|
|
||||||
ok, msg = release_gate.check_publishable_assets(as_built_today)
|
|
||||||
assert ok is False, "набор без установщика признан пригодным к публикации"
|
|
||||||
assert "HermesHubSetup.exe" in msg
|
|
||||||
assert "checksums.txt" in msg
|
|
||||||
|
|
||||||
without_checksums = tmp_path / "dist_no_sums"
|
|
||||||
without_checksums.mkdir()
|
|
||||||
(without_checksums / "HermesHubSetup.exe").write_bytes(b"exe")
|
|
||||||
ok, msg = release_gate.check_publishable_assets(without_checksums)
|
|
||||||
assert ok is False, "набор без checksums.txt признан пригодным"
|
|
||||||
assert "checksums.txt" in msg
|
|
||||||
|
|
||||||
ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого")
|
|
||||||
assert ok is False, "отсутствующий каталог сборки должен быть отказом"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_installer_with_checksum(directory, name: str, content: bytes) -> None:
|
|
||||||
"""Собрать <name> + checksums.txt с настоящим SHA-256 для теста."""
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
(directory / name).write_bytes(content)
|
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
|
||||||
(directory / "checksums.txt").write_text(f"{digest} {name}\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_publishable_assets_check_verifies_size_and_hash(tmp_path):
|
|
||||||
"""A61: присутствия файлов мало — размер и хеш проверяются по-настоящему.
|
|
||||||
|
|
||||||
Найдено живым прогоном на Windows: сборка может прерваться на середине и
|
|
||||||
оставить усечённый файл, а checksums.txt и сам установщик — разойтись ещё
|
|
||||||
до всякой публикации. Проверка одного присутствия этого не ловит.
|
|
||||||
"""
|
|
||||||
release_gate = _load_release_gate()
|
|
||||||
|
|
||||||
# 1. Настоящий размер, настоящий хеш — проходит.
|
|
||||||
good = tmp_path / "dist_good"
|
|
||||||
good.mkdir()
|
|
||||||
_write_installer_with_checksum(good, "HermesHubSetup.exe", b"X" * release_gate.MIN_PACKAGE_BYTES)
|
|
||||||
ok, msg = release_gate.check_publishable_assets(good)
|
|
||||||
assert ok is True, msg
|
|
||||||
assert "SHA-256 сошёлся" in msg
|
|
||||||
|
|
||||||
# 2. Файл меньше нижней границы — похоже на прерванную сборку.
|
|
||||||
truncated = tmp_path / "dist_truncated"
|
|
||||||
truncated.mkdir()
|
|
||||||
_write_installer_with_checksum(truncated, "HermesHubSetup.exe", b"x" * 10)
|
|
||||||
ok, msg = release_gate.check_publishable_assets(truncated)
|
|
||||||
assert ok is False, "усечённый установщик признан пригодным к публикации"
|
|
||||||
assert "мал" in msg
|
|
||||||
|
|
||||||
# 3. Хеш в checksums.txt не совпадает с настоящим файлом.
|
|
||||||
mismatched = tmp_path / "dist_mismatch"
|
|
||||||
mismatched.mkdir()
|
|
||||||
content = b"Y" * release_gate.MIN_PACKAGE_BYTES
|
|
||||||
(mismatched / "HermesHubSetup.exe").write_bytes(content)
|
|
||||||
(mismatched / "checksums.txt").write_text("0" * 64 + " HermesHubSetup.exe\n", encoding="utf-8")
|
|
||||||
ok, msg = release_gate.check_publishable_assets(mismatched)
|
|
||||||
assert ok is False, "разошедшийся хеш признан пригодным к публикации"
|
|
||||||
assert "не сошёлся" in msg
|
|
||||||
|
|
||||||
# 4. checksums.txt не называет установленный файл вовсе.
|
|
||||||
unnamed = tmp_path / "dist_unnamed"
|
|
||||||
unnamed.mkdir()
|
|
||||||
(unnamed / "HermesHubSetup.exe").write_bytes(b"Z" * release_gate.MIN_PACKAGE_BYTES)
|
|
||||||
(unnamed / "checksums.txt").write_text("0" * 64 + " other-file.bin\n", encoding="utf-8")
|
|
||||||
ok, msg = release_gate.check_publishable_assets(unnamed)
|
|
||||||
assert ok is False, "checksums.txt без нужной строки признан достаточным"
|
|
||||||
|
|
|
||||||
|
|
@ -72,27 +72,15 @@ def test_seq_token_prevents_stale_refresh_clobber():
|
||||||
seq_fresh = store.next_seq()
|
seq_fresh = store.next_seq()
|
||||||
snap_fresh = store.refresh(force_scan=False, seq=seq_fresh)
|
snap_fresh = store.refresh(force_scan=False, seq=seq_fresh)
|
||||||
gen_fresh = snap_fresh.generation
|
gen_fresh = snap_fresh.generation
|
||||||
skipped_before = store.refresh_skipped_total
|
|
||||||
|
|
||||||
# Simulate a delayed/stale response from an earlier seq counter
|
# Simulate a delayed/stale response from an earlier seq counter
|
||||||
seq_stale = seq_fresh - 1
|
seq_stale = seq_fresh - 1
|
||||||
snap_after_stale = store.refresh(force_scan=False, seq=seq_stale)
|
snap_after_stale = store.refresh(force_scan=False, seq=seq_stale)
|
||||||
|
|
||||||
# Устаревший ответ должен быть отброшен.
|
# Stale response must be rejected, retaining the fresh generation
|
||||||
#
|
assert snap_after_stale.generation == gen_fresh
|
||||||
# Проверяется именно отбрасывание, а не равенство поколений. HubStateStore —
|
|
||||||
# процессный синглтон, и фоновый сборщик квот, оставшийся от другого теста,
|
|
||||||
# успевает поднять generation между двумя вызовами. Прежнее
|
|
||||||
# `generation == gen_fresh` падало на этом с «assert 32 == 31» — примерно раз
|
|
||||||
# на десяток прогонов, только при случайном порядке тестов. Инвариант же
|
|
||||||
# другой: устаревший ответ отбрасывается, состояние назад не откатывается.
|
|
||||||
assert store.refresh_skipped_total == skipped_before + 1, (
|
|
||||||
"устаревший ответ должен быть отброшен ровно один раз"
|
|
||||||
)
|
|
||||||
assert snap_after_stale.generation >= gen_fresh, (
|
|
||||||
"состояние откатилось назад: устаревший ответ затёр более свежее"
|
|
||||||
)
|
|
||||||
assert snap_after_stale.seq != seq_stale
|
assert snap_after_stale.seq != seq_stale
|
||||||
|
assert store.refresh_skipped_total >= 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue