Compare commits
68 commits
antigravit
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3235239d5b | ||
|
|
2afd95fe3f | ||
|
|
c1ba34d369 | ||
|
|
377c567b85 | ||
|
|
d45c36c433 | ||
|
|
fc4707d9d9 | ||
|
|
372be71de7 | ||
|
|
713441ae39 | ||
|
|
ec656a0e08 | ||
|
|
922c437689 | ||
|
|
1b1143feeb | ||
|
|
1fe4549b22 | ||
|
|
7fb8c6a6c5 | ||
|
|
61e7933334 | ||
|
|
eac8352dc2 | ||
|
|
a3373f9f76 | ||
|
|
7136ab2878 | ||
|
|
89435eadb5 | ||
|
|
8b67f0dadb | ||
|
|
93da1b22fd | ||
|
|
144f6a5d59 | ||
|
|
c6981921da | ||
|
|
c0485a3721 | ||
|
|
d39189ee8e | ||
|
|
2f353778c2 | ||
|
|
2034565345 | ||
|
|
285ae7cc07 | ||
|
|
d2307362b5 | ||
|
|
ef00a64f93 | ||
|
|
30c283f705 | ||
|
|
cc18e26d1c | ||
|
|
4fa993938b | ||
|
|
58cb88e529 | ||
|
|
4860c5685e | ||
|
|
7b36538ded | ||
|
|
e431e39915 | ||
|
|
90aeceb9fd | ||
|
|
fa7bbef8af | ||
|
|
28f35f863f | ||
|
|
884a632049 | ||
|
|
6cb0d7c636 | ||
|
|
b309972e49 | ||
|
|
23e9ac1d6b | ||
|
|
14d1eb4304 | ||
|
|
6f4394113b | ||
|
|
3b423ca351 | ||
|
|
d5c8c316b4 | ||
|
|
be98fd6751 | ||
|
|
f188a18136 | ||
|
|
0ad946eccd | ||
|
|
001cd1f91d | ||
|
|
9641957d5b | ||
|
|
6af388a5dc | ||
|
|
9c57a7c607 | ||
|
|
9761362bc0 | ||
|
|
afaf600f56 | ||
|
|
2bbb9db5de | ||
|
|
eeeed36358 | ||
|
|
f269891271 | ||
|
|
266fc51adf | ||
|
|
2282f6a852 | ||
|
|
4b6533281e | ||
|
|
3e660c39bb | ||
|
|
26f7d2ce73 | ||
|
|
e74d4fbe4a | ||
|
|
b2ca7cdd4d | ||
|
|
e6eab12616 | ||
|
|
8daeafa345 |
99 changed files with 13536 additions and 443 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -6,11 +6,22 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
# Матрица из двух систем.
|
||||||
|
#
|
||||||
|
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
|
||||||
|
# держался на Windows, а тесты установки и остановки процессов молча
|
||||||
|
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
|
||||||
|
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
|
||||||
|
# проверяются одинаковым набором.
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Clean Windows Runner Test
|
name: Clean Runner Test (${{ matrix.os }})
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [windows-latest, ubuntu-latest]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -40,9 +51,13 @@ jobs:
|
||||||
python scripts/release_gate.py
|
python scripts/release_gate.py
|
||||||
|
|
||||||
headless:
|
headless:
|
||||||
name: Headless Run (no GUI dependencies)
|
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.os }}
|
||||||
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,6 +57,21 @@ 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:
|
||||||
|
|
@ -68,3 +83,12 @@ 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
|
||||||
|
|
|
||||||
114
agents/done/2026-08-26-A30-release-gate-report.md
Normal file
114
agents/done/2026-08-26-A30-release-gate-report.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Отчёт независимого оркестратора: 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`.
|
||||||
248
agents/done/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
248
agents/done/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Отчёт 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-раннера, то есть релизной джобе нужна матрица. Работа
|
||||||
|
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
||||||
|
оставлена за владельцем.
|
||||||
62
agents/inbox/2026-08-26-antigravity-dispatch-A31-A32.md
Normal file
62
agents/inbox/2026-08-26-antigravity-dispatch-A31-A32.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Передача 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 в одной ветке или одном коммите.
|
||||||
|
- Не удалять десктоп до доказанного веб-паритета.
|
||||||
|
- Не подменять живые проверки моками и не выдумывать результаты платформенных прогонов.
|
||||||
|
|
||||||
166
agents/inbox/2026-08-31-A42-provider-connect.md
Normal file
166
agents/inbox/2026-08-31-A42-provider-connect.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Задание 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`.
|
||||||
130
agents/inbox/2026-08-31-A43-frontend-canvas.md
Normal file
130
agents/inbox/2026-08-31-A43-frontend-canvas.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Задание 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`.
|
||||||
198
agents/inbox/2026-08-31-A44-restore-server-and-swap.md
Normal file
198
agents/inbox/2026-08-31-A44-restore-server-and-swap.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Задание 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`.
|
||||||
150
agents/inbox/2026-08-31-A45-moe-coder-candidates.md
Normal file
150
agents/inbox/2026-08-31-A45-moe-coder-candidates.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Задание 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`.
|
||||||
141
agents/inbox/2026-08-31-A46-frontend-mockup-redo.md
Normal file
141
agents/inbox/2026-08-31-A46-frontend-mockup-redo.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
> **ОТМЕНЕНО 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`.
|
||||||
170
agents/inbox/2026-08-31-A47-shared-memory.md
Normal file
170
agents/inbox/2026-08-31-A47-shared-memory.md
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Задание 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`.
|
||||||
174
agents/inbox/2026-08-31-A49-subagents-skills-memory.md
Normal file
174
agents/inbox/2026-08-31-A49-subagents-skills-memory.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Задание 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`.
|
||||||
166
agents/inbox/2026-08-31-A51-hub-controls-hermes.md
Normal file
166
agents/inbox/2026-08-31-A51-hub-controls-hermes.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Задание 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`.
|
||||||
295
agents/inbox/2026-08-31-A52-local-model-supervisor.md
Normal file
295
agents/inbox/2026-08-31-A52-local-model-supervisor.md
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
# Задание 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`.
|
||||||
122
agents/inbox/2026-08-31-A55-account-connection-remaining.md
Normal file
122
agents/inbox/2026-08-31-A55-account-connection-remaining.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Задание 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`.
|
||||||
141
agents/inbox/2026-09-01-A56-context-compression.md
Normal file
141
agents/inbox/2026-09-01-A56-context-compression.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Задание 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`.
|
||||||
169
agents/inbox/2026-09-01-A57-agy-native-login.md
Normal file
169
agents/inbox/2026-09-01-A57-agy-native-login.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Задание A57: вход в Antigravity через сам agy, а не через сочинённый файл
|
||||||
|
|
||||||
|
## Дата поступления
|
||||||
|
2026-09-01
|
||||||
|
|
||||||
|
## База
|
||||||
|
|
||||||
|
`origin/main` (`f188a18`).
|
||||||
|
|
||||||
|
```
|
||||||
|
git fetch origin --prune
|
||||||
|
git checkout -b antigravity/a57-agy-native-login origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
В `main` напрямую не пушить.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
|
||||||
|
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
|
||||||
|
|
||||||
|
Зона: подключение аккаунтов Antigravity. С A56 (сжатие контекста) не пересекается.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Сейчас хаб проводит вход сам: получает токены по OAuth и **записывает чужой файл учётных данных своим кодом**. Формат этого файла ревьюер восстановил по рабочему профилю владельца и по строкам в бинарнике `agy`. Сегодня это работает. Но `agy` обновляется, и формат может измениться молча: файл останется на месте, вход перестанет засчитываться, а владелец увидит ровно ту необъяснимую картину, которую ловили полдня — «Авторизация успешно завершена» и тут же «Please sign in to view available models».
|
||||||
|
|
||||||
|
Правильный ответ — дать войти самому `agy`, с `HOME`, указывающим на каталог профиля. Тогда он пишет свои файлы своим форматом, сам обновляет токен по истечении часа, и гадать не о чем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что проверено ревьюером — заново не выяснять
|
||||||
|
|
||||||
|
### Неинтерактивного входа у agy нет
|
||||||
|
|
||||||
|
`agy --help` (проверено запуском) даёт подкоманды: `agent`, `agents`, `changelog`, `help`, `install`, `mcp`, `mic-serve`, `models`, `plugin`, `plugins`, `update`. Подкоманд `login` или `auth` **нет**. Вход один: запустить CLI без аргументов и пройти его в терминале. Это же говорит текст ошибки самого agy:
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Please sign in to view available models.
|
||||||
|
Launch the CLI without arguments to sign in.
|
||||||
|
```
|
||||||
|
|
||||||
|
Искать скрытый флаг входа не надо — его нет.
|
||||||
|
|
||||||
|
### Что agy держит в каталоге профиля
|
||||||
|
|
||||||
|
Рабочий профиль владельца `ag-orch-fallback` (11 моделей):
|
||||||
|
|
||||||
|
```
|
||||||
|
.gemini/oauth_creds.json 1949 байт формат Gemini CLI
|
||||||
|
.gemini/antigravity-cli/antigravity-oauth-token 505 байт {"auth_method":"consumer","token":{...}}
|
||||||
|
.gemini/antigravity-cli/settings.json 107 байт enableTelemetry, trustedWorkspaces
|
||||||
|
.gemini/antigravity-cli/installation_id 36 байт
|
||||||
|
.gemini/antigravity-cli/jetski_state.pbtxt
|
||||||
|
.gemini/antigravity-cli/history.jsonl
|
||||||
|
.gemini/antigravity-cli/conversation_summaries.db
|
||||||
|
auth.json файл хаба, не agy
|
||||||
|
```
|
||||||
|
|
||||||
|
Вход agy читает из `antigravity-oauth-token`, а не из `oauth_creds.json`. Это установлено сравнением рабочего профиля с неработающим и подтверждено исполнением: файл создали руками для `ag-4` — `agy models` тут же выдал 11 моделей.
|
||||||
|
|
||||||
|
### Изоляция по HOME уже работает
|
||||||
|
|
||||||
|
`get_profile_env_dir`, `build_safe_subprocess_env` и `hidden_process_kwargs` существуют и применяются: `agy models` вызывается с `HOME`/`USERPROFILE`, указывающими на каталог профиля. Заново это писать не надо.
|
||||||
|
|
||||||
|
### Заготовка уже есть
|
||||||
|
|
||||||
|
`launch_native_agy_login` в `agy_subprocess.py` запускает agy с `CREATE_NEW_CONSOLE`. Её никто не вызывает — числится мёртвым кодом с A54. Это отправная точка, а не мусор.
|
||||||
|
|
||||||
|
### Рост номеров профилей починен
|
||||||
|
|
||||||
|
Слот выбирался до входа, когда почта ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный номер: один аккаунт владельца расползся на ag-2, ag-3, ag-4. В `9641957` после опознания почты учётные данные возвращаются в слот, который этот аккаунт уже занимает. **Не переделывать.**
|
||||||
|
|
||||||
|
### Среда владельца
|
||||||
|
|
||||||
|
Сервер: Ubuntu 24.04, рабочий стол на месте (Chrome запускается), владелец сидит за машиной. Вторая машина — Windows 11. Хаб работает на обеих.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-1. Вход выполняет сам agy
|
||||||
|
|
||||||
|
1. **Запуск `agy` в настоящем терминале** с `HOME` и `USERPROFILE`, указывающими на каталог выбранного профиля. Владелец проходит вход глазами и руками — это единственный доступный способ.
|
||||||
|
2. **Терминал не подразумевать, а искать.** На Linux проверить наличие эмулятора (`x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm`) и назвать в отказе, что именно искали. Отсутствие терминала — честный отказ с перечнем проверенного, а не молчание.
|
||||||
|
3. **Файл учётных данных хаб больше не сочиняет.** `write_agy_oauth_creds` и запись `antigravity-oauth-token` остаются только для запасного браузерного пути (P0-4).
|
||||||
|
4. **Ждать окончания входа по появлению файла**, а не по коду возврата терминала: эмулятор часто отсоединяется сразу. Ждать `antigravity-oauth-token` в каталоге профиля с разумным пределом и внятным сообщением по его истечении.
|
||||||
|
|
||||||
|
## P0-2. Слот выбирается до входа и не меняется
|
||||||
|
|
||||||
|
1. **Каталог профиля определяется заранее** и передаётся через `HOME`. Вход физически не может уйти в чужой каталог — в этом весь смысл.
|
||||||
|
2. **Занятый слот не перезаписывать.** Если в каталоге уже лежит рабочий вход другого аккаунта, предупредить и потребовать подтверждения.
|
||||||
|
3. **Каталоги существующих аккаунтов не трогать.** Их два десятка, повторный вход руками стоит владельцу часов.
|
||||||
|
|
||||||
|
## P0-3. Почта берётся из профиля, а не выдумывается
|
||||||
|
|
||||||
|
1. **После входа опознать аккаунт**, прочитав то, что записал agy. Если почту установить не удалось — показать `Н/Д` с причиной, а не подставить правдоподобное.
|
||||||
|
2. **Проверить двойников** уже существующим `AutoAssigner.check_duplicate_identity` и вернуть учётные данные в занятый этим аккаунтом слот, если он есть.
|
||||||
|
3. **Число и время получения моделей** показывать рядом со списком.
|
||||||
|
|
||||||
|
## P0-4. Браузерный путь остаётся запасным
|
||||||
|
|
||||||
|
1. **Не удалять существующий OAuth.** С другой машины через браузер это единственный способ, и он работает.
|
||||||
|
2. **Владелец выбирает способ** в мастере: вход в терминале на этой машине или по ссылке из браузера. Предлагать первым тот, который на текущей машине выполним.
|
||||||
|
3. **Текст объясняет разницу**: терминал доступен только там, где стоит хаб.
|
||||||
|
|
||||||
|
## P0-5. Отказ доходит до владельца
|
||||||
|
|
||||||
|
1. **Причина отказа — текстом.** `agy` пишет её в stderr; она обязана попадать в интерфейс вместе с указанием `HOME`, с которым шёл запуск.
|
||||||
|
2. **Пустых сообщений быть не должно.** Запасной текст «Отказ выполнения действия» означает потерянную причину.
|
||||||
|
3. **Проверка после входа не блокирует ответ.** Действие возвращается сразу, опрос провайдера идёт в фоне — это сделано в `0ad946e`, не откатывать.
|
||||||
|
|
||||||
|
## P0-6. Безопасность
|
||||||
|
|
||||||
|
1. **Токены и коды в интерфейс не выводить и в журнал не писать.** В сообщениях допустимы пути и имена файлов, но не содержимое.
|
||||||
|
2. **Права на каталог и файлы** — `0700` и `0600`.
|
||||||
|
3. **`~/.hermes/agy_profiles/` не чистить** и не трогать чужие профили.
|
||||||
|
|
||||||
|
## P0-7. Проверка исполнением
|
||||||
|
|
||||||
|
Тестами это не ловится: все прежние дефекты входа прошли через зелёный прогон.
|
||||||
|
|
||||||
|
1. **Подключить аккаунт через терминал на сервере** и приложить вывод `agy models` для этого профиля.
|
||||||
|
2. **Подключить второй аккаунт** и убедиться, что первый не задет: у обоих свои каталоги и свои почты.
|
||||||
|
3. **Повторить вход тем же аккаунтом** и убедиться, что новый слот не создаётся.
|
||||||
|
4. **Проверить отказ** при отсутствии терминала: сообщение перечисляет, что искали.
|
||||||
|
5. **Проверить, что браузерный путь по-прежнему работает** с другой машины.
|
||||||
|
|
||||||
|
## P0-8. Аудит вторым проходом
|
||||||
|
|
||||||
|
1. **Пройти вход целиком на обеих машинах**, скриншоты приложить.
|
||||||
|
2. **Убедиться, что хаб не пишет `antigravity-oauth-token`** на пути через CLI: файл создаёт agy.
|
||||||
|
3. **Проверить, что при отказе входа профиль не остаётся наполовину заполненным** и не числится подключённым.
|
||||||
|
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
|
||||||
|
5. **Побочные изменения** объяснить.
|
||||||
|
6. **Пропущенный пункт назвать пропущенным.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Учётные данные и каталоги существующих аккаунтов не трогать.
|
||||||
|
- Правки ревьюера из `main` не откатывать: `9641957` (запись токена и слоты), `0ad946e` (проверка в фоне), `f188a18` (устаревший вердикт).
|
||||||
|
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
||||||
|
- Адреса, пути и имена терминалов в код не зашивать вслепую: искать и сообщать, что искали.
|
||||||
|
- Версию `0.1.3` не понижать.
|
||||||
|
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной; отсутствие прав — не то же самое, что отсутствие файла.
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
1. Ветка в `origin`, `git status` чист.
|
||||||
|
2. Вход через `agy` в терминале работает на Linux и на Windows; вывод `agy models` приложен.
|
||||||
|
3. Файл `antigravity-oauth-token` на этом пути создаёт agy, а не хаб.
|
||||||
|
4. Слот задаётся до входа через `HOME`; чужой каталог затронуть невозможно.
|
||||||
|
5. Повторный вход тем же аккаунтом не создаёт новый слот.
|
||||||
|
6. Почта берётся из профиля; неустановленная показывается как `Н/Д` с причиной.
|
||||||
|
7. Браузерный путь сохранён и проверен с другой машины.
|
||||||
|
8. Отсутствие терминала даёт отказ с перечнем проверенного.
|
||||||
|
9. Токены не попадают ни в интерфейс, ни в журнал.
|
||||||
|
10. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **626**.
|
||||||
|
11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||||
|
|
||||||
|
## Главное
|
||||||
|
|
||||||
|
Хаб сегодня подделывает чужой формат учётных данных. Это работает ровно до следующего обновления `agy`, и отказ будет молчаливым: файл на месте, вход не засчитан, причина неочевидна. Владелец уже потерял на этом день.
|
||||||
|
|
||||||
|
`agy` умеет входить сам и делает это правильно по определению. Задание переносит вход туда, где ему место, оставляя браузерный путь для удалённого случая.
|
||||||
|
|
||||||
|
## Порядок сдачи
|
||||||
|
Передать точный `FINAL_COMMIT_SHA`.
|
||||||
151
agents/inbox/2026-09-02-A58-agy-eligibility-state.md
Normal file
151
agents/inbox/2026-09-02-A58-agy-eligibility-state.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Задание A58: хаб видит состояние проверки доступности agy
|
||||||
|
|
||||||
|
## Дата поступления
|
||||||
|
2026-09-02
|
||||||
|
|
||||||
|
## База
|
||||||
|
|
||||||
|
`origin/main` (`e431e39`).
|
||||||
|
|
||||||
|
```
|
||||||
|
git fetch origin --prune
|
||||||
|
git checkout -b antigravity/a58-agy-eligibility-state origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
В `main` напрямую не пушить.
|
||||||
|
|
||||||
|
## Порядок исполнения
|
||||||
|
|
||||||
|
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
|
||||||
|
|
||||||
|
Зона: обнаружение состояния `agy`. С кодом входа (A57) пересекается только чтением пути к исполняемому файлу.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
Владелец не может пользоваться Antigravity: `agy` отвечает
|
||||||
|
|
||||||
|
```
|
||||||
|
Eligibility check failed: Your current account is not eligible for Antigravity,
|
||||||
|
because it is not currently available in your location.
|
||||||
|
```
|
||||||
|
|
||||||
|
Вход при этом проходит полностью: терминал открывается, каталог профиля верный, аккаунт опознан. Отказывает Google.
|
||||||
|
|
||||||
|
Владелец обходит это сторонним патчером, который держит у себя. После каждого обновления `agy` патч слетает, и владелец узнаёт об этом, только наткнувшись на отказ посреди работы. Задание закрывает именно это: **хаб должен замечать смену состояния и говорить о ней**, а не оставлять владельца выяснять причину заново.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что проверено ревьюером — заново не выяснять
|
||||||
|
|
||||||
|
### Проверка не в клиенте, но отказ выносит клиент
|
||||||
|
|
||||||
|
В бинарнике `agy` **нет** строки «not currently available in your location» — её присылает сервер. При этом есть перечисление `EPD_ELIGIBILITY_NOT_ELIGIBLE_REGION_OUT_OF_SCOPE`, `ENDPOINT_AIM_ELIGIBILITY` и `AIM_ELIGIBILITY_FETCH_STATUS_*`: клиент запрашивает право доступа у Google и отказывается работать сам.
|
||||||
|
|
||||||
|
### Прокси не помогает — ограничение привязано к аккаунту
|
||||||
|
|
||||||
|
Измерено на сервере владельца: выход через Финляндию и через Данию даёт один и тот же отказ. Описание патчера это подтверждает — он снимает ограничение «без VPN и смены региона аккаунта Google», то есть обычные пути именно эти два.
|
||||||
|
|
||||||
|
**Поддержку прокси, добавленную в `fa7bbef`, не удалять**: она нужна и работает, просто эту задачу не решает.
|
||||||
|
|
||||||
|
### Что делает патчер
|
||||||
|
|
||||||
|
Для CLI это правка машинного кода, а не настройка. Ищется последовательность байтов и переписывается так, чтобы ветвление всегда уходило по разрешённому пути:
|
||||||
|
|
||||||
|
```
|
||||||
|
было: test rax,rax ; je eligible ; cmp byte[rax+8],0 ; jne eligible ; call failure
|
||||||
|
стало: test rax,rax ; je eligible ; test rax,rax ; nop ; jne eligible
|
||||||
|
```
|
||||||
|
|
||||||
|
Четыре байта. В исходнике патчера это названо «единственный гейт начальной проверки». Есть подписи для x86-64 и для arm64.
|
||||||
|
|
||||||
|
Подменять в настройках нечего: проверка не в настройках.
|
||||||
|
|
||||||
|
### Версии
|
||||||
|
|
||||||
|
У владельца `Antigravity CLI 1.1.23`. Патчер объявляет минимальные версии `2.5.5` и `2.9.1` — соответствие надо проверить, иначе подпись не найдётся. Обновление CLI выполняется командой `agy update` и перезаписывает файл, снимая патч.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-1. Хаб не патчит ничего сам
|
||||||
|
|
||||||
|
1. **Хаб не изменяет исполняемые файлы.** Ни при каких условиях, ни по кнопке, ни автоматически.
|
||||||
|
2. **Хаб ничего не скачивает и не запускает из сети.** Стороннего кода в хабе нет.
|
||||||
|
3. **Только чтение**: состояние определяется чтением файла, который уже лежит на машине.
|
||||||
|
|
||||||
|
## P0-2. Состояние определяется и показывается
|
||||||
|
|
||||||
|
1. **Признак патча** — по наличию в файле изменённой последовательности байтов. Подписи для x86-64 и arm64. Чтение файла, ничего больше.
|
||||||
|
2. **Три состояния, а не два**: «проверка снята», «проверка на месте», «определить не удалось» — с причиной. Не найдена подпись ни в исходном, ни в изменённом виде означает именно третье: другая версия CLI, а не «не пропатчен».
|
||||||
|
3. **Версия и отпечаток файла** запоминаются. Смена любого из них после обновления — повод пересчитать состояние и сказать владельцу.
|
||||||
|
4. **Показывать в карточке аккаунта Antigravity** и в «Состоянии системы».
|
||||||
|
|
||||||
|
## P0-3. Владелец узнаёт о смене состояния
|
||||||
|
|
||||||
|
1. **Событие в журнале**, когда состояние сменилось: было «снята» — стало «на месте».
|
||||||
|
2. **Заметное указание в интерфейсе**, а не строчка в глубине: этот отказ останавливает работу целиком.
|
||||||
|
3. **Не опрашивать в цикле.** Достаточно проверки при запуске, после обновления `agy` и по кнопке. Опрос в цикле уже приводил к тому, что интерфейс сам себя кормил запросами.
|
||||||
|
|
||||||
|
## P0-4. Кнопка запускает то, что владелец сам поставил
|
||||||
|
|
||||||
|
1. **Путь к сценарию владельца задаётся в настройках.** Умолчания не выдумывать: не задан — кнопки нет, показывается `Н/Д: сценарий не указан`.
|
||||||
|
2. **Запуск только по нажатию.** Никакого автоматического запуска при обновлении: владелец должен видеть, что и когда выполняется.
|
||||||
|
3. **Запуск в терминале**, как вход в A57 — владелец видит ход и результат. Использовать существующий `find_terminal_emulator`, заново не писать.
|
||||||
|
4. **После выполнения состояние пересчитывается** и показывается новое.
|
||||||
|
5. **Отказ запуска доходит текстом**, с указанием пути и причины.
|
||||||
|
|
||||||
|
## P0-5. Обновление CLI
|
||||||
|
|
||||||
|
1. **Показывать установленную версию** `agy`. Не удалось определить — `Н/Д` с причиной.
|
||||||
|
2. **Кнопка обновления** выполняет `agy update` в терминале.
|
||||||
|
3. **Предупредить о порядке**: обновление перезаписывает файл и снимает патч, поэтому сначала обновление, потом патч. Это подсказка в интерфейсе, а не комментарий в коде.
|
||||||
|
|
||||||
|
## P0-6. Проверка исполнением
|
||||||
|
|
||||||
|
1. **Определить состояние на настоящем `agy` владельца** и приложить вывод.
|
||||||
|
2. **Проверить все три состояния**, третье — на файле другой версии.
|
||||||
|
3. **Проверить, что хаб файл не изменил**: контрольная сумма до и после определения состояния совпадает.
|
||||||
|
4. **Проверить кнопку** на незаданном пути и на неверном.
|
||||||
|
|
||||||
|
## P0-7. Аудит вторым проходом
|
||||||
|
|
||||||
|
1. **Убедиться, что хаб не пишет в исполняемый файл** ни на одном пути.
|
||||||
|
2. **Проверить, что «определить не удалось» не выдаётся за «не пропатчен»** — это разные вещи, и путать их нельзя.
|
||||||
|
3. **Проверить, что нет опроса в цикле.**
|
||||||
|
4. **Побочные изменения** объяснить.
|
||||||
|
5. **Пропущенный пункт назвать пропущенным.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Хаб не изменяет чужие исполняемые файлы и не выполняет загруженный из сети код.
|
||||||
|
- Поддержку прокси из `fa7bbef` не удалять.
|
||||||
|
- Правки ревьюера из `main` не откатывать.
|
||||||
|
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
||||||
|
- Пути и версии в код не зашивать: определять и сообщать, что проверяли.
|
||||||
|
- Версию `0.1.3` не понижать.
|
||||||
|
- Правило честности без исключений: неопределённое — `Н/Д` с причиной.
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
1. Ветка в `origin`, `git status` чист.
|
||||||
|
2. Состояние проверки определяется на настоящем `agy`; вывод приложен.
|
||||||
|
3. Три состояния различаются; «определить не удалось» называет причину.
|
||||||
|
4. Контрольная сумма исполняемого файла до и после определения совпадает.
|
||||||
|
5. Смена состояния после обновления `agy` попадает в журнал и видна в интерфейсе.
|
||||||
|
6. Кнопка запускает указанный владельцем сценарий в терминале; путь не задан — кнопки нет.
|
||||||
|
7. Показывается версия `agy`; есть кнопка `agy update` с предупреждением о порядке.
|
||||||
|
8. Опроса в цикле нет.
|
||||||
|
9. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **704**.
|
||||||
|
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||||
|
|
||||||
|
## Главное
|
||||||
|
|
||||||
|
Владелец теряет время не на сам обход, а на то, что узнаёт о слетевшем патче случайно — посреди работы, по невнятному отказу. Хаб для того и нужен, чтобы состояние было видно заранее.
|
||||||
|
|
||||||
|
Поэтому хаб **смотрит и говорит**, а действие остаётся за владельцем и выполняется его собственным средством. Патчить чужой бинарник самому хабу нельзя: подпись привязана к версии, любое обновление её ломает, и отлаживать пришлось бы чужой код внутри своего.
|
||||||
|
|
||||||
|
## Порядок сдачи
|
||||||
|
Передать точный `FINAL_COMMIT_SHA`.
|
||||||
151
agents/inbox/2026-09-02-A59-visible-update.md
Normal file
151
agents/inbox/2026-09-02-A59-visible-update.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Задание 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`.
|
||||||
232
agents/inbox/2026-09-02-A60-update-completes.md
Normal file
232
agents/inbox/2026-09-02-A60-update-completes.md
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
# Задание 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`.
|
||||||
150
agents/inbox/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
150
agents/inbox/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# Задание 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.
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
# Задание 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.
|
||||||
171
benchmarks/measure_a56_live.py
Normal file
171
benchmarks/measure_a56_live.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""Live validation and benchmark for Task A56: Context Compression.
|
||||||
|
|
||||||
|
Directly tests live Qwen3-4B-2507 compressor on port 8082:
|
||||||
|
1. Verifies /props and n_ctx = 32768.
|
||||||
|
2. Verifies exact token counting via /tokenize.
|
||||||
|
3. Tests prompt compression on large technical context with exact file paths, ports, IPs, SHAs, version numbers, metrics.
|
||||||
|
4. Validates 100% fact retention.
|
||||||
|
5. Saves results to /srv/projects/AI-Memory/01_PROJECTS/hermes-hub/compression_memory.json.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
|
||||||
|
from antigravity_provider.router.context_compressor import (
|
||||||
|
COMPRESSED_BLOCK_END,
|
||||||
|
COMPRESSED_BLOCK_START,
|
||||||
|
ContextCompressor,
|
||||||
|
extract_factual_entities,
|
||||||
|
verify_facts_retention,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
from antigravity_provider.router.router_config import RouterProfileConfig
|
||||||
|
|
||||||
|
|
||||||
|
def run_live_compression_benchmark():
|
||||||
|
print("=" * 70)
|
||||||
|
print("Task A56: Live Context Compressor Verification (Port 8082)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# 1. Health check
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:8082/health")
|
||||||
|
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
print(f"[OK] Compressor Health: {data}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Compressor not reachable on 8082: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Props check
|
||||||
|
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8082")
|
||||||
|
props = supervisor.query_server_props()
|
||||||
|
print(f"[OK] Server Props: n_ctx = {props.n_ctx}, model = {props.model_name}, measured = {props.is_measured}")
|
||||||
|
|
||||||
|
# 3. Build realistic technical conversation history with diverse facts
|
||||||
|
history_messages = [
|
||||||
|
{"role": "system", "content": "You are the Antigravity senior orchestrator for Hermes Hub."},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Task context initialization:\n"
|
||||||
|
"- Server host: 192.168.1.81, Web backend on port 8765 (/srv/projects/Agent projects/hermes-hub)\n"
|
||||||
|
"- Primary Coder: Qwen3-Coder-30B-A3B on port 8081 with 224K context (229376 tokens), speed 107.4 tok/s, VRAM 30008 MiB\n"
|
||||||
|
"- Compressor model: Qwen3-4B-2507 on port 8082 with 32K context (32768 tokens) running on CPU with 32 threads\n"
|
||||||
|
"- Baseline commit SHA: 26f7d2c, current release version: v0.1.2\n"
|
||||||
|
"- Central AI Memory Vault: /srv/projects/AI-Memory/01_PROJECTS/hermes-hub\n"
|
||||||
|
"- Code modules: LocalSupervisor in src/antigravity_provider/router/local_supervisor.py, DualCoderPipeline in src/antigravity_provider/router/dual_coder_pipeline.py\n"
|
||||||
|
"- Safety boundary: Context truncation margin 1024 tokens, response margin 4096 tokens"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"Understood. System parameters and hardware topography recorded:\n"
|
||||||
|
"- Host 192.168.1.81:8765\n"
|
||||||
|
"- Port 8081 (Coder: 229376 n_ctx, 107.4 tok/s, 30008 MiB)\n"
|
||||||
|
"- Port 8082 (Compressor: 32768 n_ctx, CPU ngl 0)\n"
|
||||||
|
"- SHA 26f7d2c, version v0.1.2\n"
|
||||||
|
"- Ready for workflow execution."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"Step 1 Execution Details:\n"
|
||||||
|
"- Modified src/antigravity_provider/router/adapters/local_adapter.py to integrate context compression\n"
|
||||||
|
"- Added role definition local-supervisor to RoleRegistry in src/antigravity_provider/router/role_registry.py\n"
|
||||||
|
"- Test suite tests/test_a56_context_compression.py executed with 10 unit tests\n"
|
||||||
|
"- Memory log written to /srv/projects/AI-Memory/01_PROJECTS/hermes-hub/local_models_memory.json\n"
|
||||||
|
"- Performance measurement: prompt speed 853.9 tok/s, generation speed 5.4 tok/s on CPU"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"Step 1 verified successfully.\n"
|
||||||
|
"- local_adapter.py updated\n"
|
||||||
|
"- role_registry.py updated\n"
|
||||||
|
"- 853.9 tok/s prompt ingestion confirmed\n"
|
||||||
|
"- Memory synced to /srv/projects/AI-Memory."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
# Fresh window (last 2 messages)
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Step 2: What is the current status of all services on 192.168.1.81?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "All services on 192.168.1.81 (ports 8081, 8082, 8765) are healthy and active.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n[INFO] Starting Context Compression on Live Server (port 8082)...")
|
||||||
|
compressor = ContextCompressor()
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="live-compressor",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["default"],
|
||||||
|
)
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
compressed_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=history_messages,
|
||||||
|
target_context_limit=32768,
|
||||||
|
current_token_count=1200,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=0.0, # force compression
|
||||||
|
keep_recent_messages=2,
|
||||||
|
timeout_sec=60.0,
|
||||||
|
)
|
||||||
|
total_time = time.time() - t0
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("LIVE COMPRESSION RESULTS")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"Status: {outcome.status}")
|
||||||
|
print(f"Status Message: {outcome.status_message}")
|
||||||
|
print(f"Tokens Before: {outcome.tokens_before}")
|
||||||
|
print(f"Tokens After: {outcome.tokens_after}")
|
||||||
|
print(f"Tokens Saved: {outcome.saved_tokens}")
|
||||||
|
print(f"Compression Ratio: {outcome.compression_ratio}x")
|
||||||
|
print(f"Duration: {outcome.duration_sec}s (Total wall time: {total_time:.2f}s)")
|
||||||
|
print(f"Model / Build: {outcome.gguf_name}")
|
||||||
|
print(f"Fact Retention: {outcome.facts_retained}/{outcome.facts_total} ({outcome.retention_percent}%)")
|
||||||
|
print("\nRetained Facts:")
|
||||||
|
for f in outcome.retained_facts:
|
||||||
|
print(f" ✓ {f}")
|
||||||
|
|
||||||
|
if outcome.missing_facts:
|
||||||
|
print("\nMissing Facts:")
|
||||||
|
for f in outcome.missing_facts:
|
||||||
|
print(f" ✗ {f}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("COMPRESSED MESSAGE PREVIEW")
|
||||||
|
print("=" * 70)
|
||||||
|
for i, msg in enumerate(compressed_msgs):
|
||||||
|
print(f"\n--- Message {i+1} [{msg.get('role')}] ---")
|
||||||
|
print(msg.get("content"))
|
||||||
|
|
||||||
|
# Verify key assertions
|
||||||
|
assert outcome.status == "SUCCESS", f"Expected SUCCESS, got {outcome.status}"
|
||||||
|
assert outcome.retention_percent == 100.0, f"Expected 100% retention, got {outcome.retention_percent}%"
|
||||||
|
assert len(compressed_msgs) == 4 # system + compressed + 2 fresh
|
||||||
|
|
||||||
|
print("\n[SUCCESS] Live Context Compression Benchmark PASSED 100%!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = run_live_compression_benchmark()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"hub_version": "0.1.1",
|
"hub_version": "0.1.3",
|
||||||
"min_hermes_version": "0.20.0",
|
"min_hermes_version": "0.20.0",
|
||||||
"max_tested_hermes_version": "0.20.4",
|
"max_tested_hermes_version": "0.20.4",
|
||||||
"tested_versions": [
|
"tested_versions": [
|
||||||
|
|
|
||||||
22
docs/research/README.md
Normal file
22
docs/research/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Разведка и идеи
|
||||||
|
|
||||||
|
Здесь — то, что рассмотрено для 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
|
||||||
|
по фазам, положен в репозиторий как артефакт.
|
||||||
71
docs/research/agent-orchestrator.md
Normal file
71
docs/research/agent-orchestrator.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Разбор: 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).
|
||||||
113
docs/research/kagent-merge-decision.md
Normal file
113
docs/research/kagent-merge-decision.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Решение: слияние 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`.
|
||||||
107
docs/research/kagent-merge-plan.md
Normal file
107
docs/research/kagent-merge-plan.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# План слияния 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 (**подтверждено: отсутствует**).
|
||||||
94
docs/research/scouting-log.md
Normal file
94
docs/research/scouting-log.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Журнал разведки
|
||||||
|
|
||||||
|
Что рассмотрено для 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, передать в торговые проекты.**
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
@ -14,11 +14,11 @@ namespace HermesHubSetup
|
||||||
{
|
{
|
||||||
public class SetupEngine
|
public class SetupEngine
|
||||||
{
|
{
|
||||||
public const string HUB_VERSION = "0.1.1";
|
public const string HUB_VERSION = "0.1.3";
|
||||||
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
|
||||||
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
|
||||||
// какого кода собран установщик.
|
// какого кода собран установщик.
|
||||||
public const string BuildCommit = "ef6a9e1";
|
public const string BuildCommit = "e431e39";
|
||||||
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,7 +86,17 @@ namespace HermesHubSetup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub");
|
// GetFolderPath(LocalApplicationData) не всегда возвращает то, что
|
||||||
|
// ждёт установщик — найдено живым прогоном (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
|
||||||
|
|
@ -264,6 +274,19 @@ namespace HermesHubSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
|
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
|
||||||
|
public static string StopWarning = "";
|
||||||
|
|
||||||
|
// Процесс мог не успеть отпустить файл. Один отказ по занятости — не приговор.
|
||||||
|
static void CopyWithRetry(string source, string destination)
|
||||||
|
{
|
||||||
|
for (int attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
try { File.Copy(source, destination, true); return; }
|
||||||
|
catch (IOException) { if (attempt >= 5) throw; System.Threading.Thread.Sleep(700); }
|
||||||
|
catch (UnauthorizedAccessException) { if (attempt >= 5) throw; System.Threading.Thread.Sleep(700); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void StopOwnedRuntime(string home, bool includeLauncher)
|
public static void StopOwnedRuntime(string home, bool includeLauncher)
|
||||||
{
|
{
|
||||||
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
|
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
|
||||||
|
|
@ -296,7 +319,18 @@ namespace HermesHubSetup
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
StopOwnedRuntime(HermesHome, true);
|
// Неудачная остановка прежнего хаба не повод отменять установку.
|
||||||
|
// Раньше любой ненулевой выход скрипта останавливал всё, и владелец
|
||||||
|
// видел голый код 15. Если процесс уцелел, копирование само скажет,
|
||||||
|
// какой файл занят.
|
||||||
|
try { StopOwnedRuntime(HermesHome, true); }
|
||||||
|
catch (Exception stopEx)
|
||||||
|
{
|
||||||
|
StopWarning = stopEx.Message;
|
||||||
|
if (progressCallback != null)
|
||||||
|
progressCallback("Не удалось остановить прежний Hermes Hub: " + stopEx.Message
|
||||||
|
+ ". Продолжаю установку.", 5);
|
||||||
|
}
|
||||||
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
|
||||||
if (!Directory.Exists(TargetInstallDir))
|
if (!Directory.Exists(TargetInstallDir))
|
||||||
{
|
{
|
||||||
|
|
@ -313,8 +347,8 @@ namespace HermesHubSetup
|
||||||
|
|
||||||
if (File.Exists(launcherSrc))
|
if (File.Exists(launcherSrc))
|
||||||
{
|
{
|
||||||
File.Copy(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"), true);
|
CopyWithRetry(launcherSrc, Path.Combine(TargetInstallDir, "HermesHub.exe"));
|
||||||
File.Copy(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"), true);
|
CopyWithRetry(launcherSrc, Path.Combine(HermesHome, "HermesHub.exe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe");
|
string webLauncherSrc = Path.Combine(sourceRoot, @"launcher\HermesHubWeb.exe");
|
||||||
|
|
@ -325,15 +359,15 @@ namespace HermesHubSetup
|
||||||
|
|
||||||
if (File.Exists(webLauncherSrc))
|
if (File.Exists(webLauncherSrc))
|
||||||
{
|
{
|
||||||
File.Copy(webLauncherSrc, Path.Combine(TargetInstallDir, "HermesHubWeb.exe"), true);
|
CopyWithRetry(webLauncherSrc, Path.Combine(TargetInstallDir, "HermesHubWeb.exe"));
|
||||||
File.Copy(webLauncherSrc, Path.Combine(HermesHome, "HermesHubWeb.exe"), true);
|
CopyWithRetry(webLauncherSrc, Path.Combine(HermesHome, "HermesHubWeb.exe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy Setup.exe itself to target dir for uninstaller/repair
|
// Copy Setup.exe itself to target dir for uninstaller/repair
|
||||||
string setupSrc = Process.GetCurrentProcess().MainModule.FileName;
|
string setupSrc = Process.GetCurrentProcess().MainModule.FileName;
|
||||||
if (File.Exists(setupSrc))
|
if (File.Exists(setupSrc))
|
||||||
{
|
{
|
||||||
try { File.Copy(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe"), true); } catch { }
|
try { CopyWithRetry(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe")); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Install UI & System Dependencies into Hermes Python Environment
|
// 2. Install UI & System Dependencies into Hermes Python Environment
|
||||||
|
|
@ -392,7 +426,7 @@ namespace HermesHubSetup
|
||||||
string templateConfig = Path.Combine(sourceRoot, @"config\router_profiles.example.yaml");
|
string templateConfig = Path.Combine(sourceRoot, @"config\router_profiles.example.yaml");
|
||||||
if (!File.Exists(runtimeConfig) && File.Exists(templateConfig))
|
if (!File.Exists(runtimeConfig) && File.Exists(templateConfig))
|
||||||
{
|
{
|
||||||
File.Copy(templateConfig, runtimeConfig, true);
|
CopyWithRetry(templateConfig, runtimeConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Create Start Menu Shortcut
|
// 6. Create Start Menu Shortcut
|
||||||
|
|
@ -529,7 +563,7 @@ namespace HermesHubSetup
|
||||||
string fileName = Path.GetFileName(file);
|
string fileName = Path.GetFileName(file);
|
||||||
srcFiles.Add(fileName);
|
srcFiles.Add(fileName);
|
||||||
string destFile = Path.Combine(dst, fileName);
|
string destFile = Path.Combine(dst, fileName);
|
||||||
File.Copy(file, destFile, true);
|
CopyWithRetry(file, destFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove destination files that do not exist in source or are .pyc
|
// Remove destination files that do not exist in source or are .pyc
|
||||||
|
|
@ -571,6 +605,12 @@ 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);
|
||||||
|
|
@ -617,6 +657,7 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,31 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
HUB_VERSION="0.1.1"
|
HUB_VERSION="0.1.3"
|
||||||
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
||||||
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
||||||
|
|
||||||
|
# Установка пользовательская: всё ложится в $HOME/.hermes и $HOME/.local/bin,
|
||||||
|
# службы не ставятся, root не нужен. Под sudo домашним каталогом становится
|
||||||
|
# /root, венв Hermes там не находится, установщик сваливается на системный
|
||||||
|
# python — а он в Ubuntu 24.04 закрыт для pip (PEP 668). В итоге установка
|
||||||
|
# уходит в /root/.hermes, где владелец её не видит, и падает на проверке.
|
||||||
|
# Молча ставить не туда нельзя, поэтому отказываемся сразу и по делу.
|
||||||
|
if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && [ -z "${HERMES_ALLOW_ROOT:-}" ]; then
|
||||||
|
echo "❌ Установщик запущен через sudo." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Hermes Hub ставится в домашний каталог пользователя, а под sudo" >&2
|
||||||
|
echo " это /root — туда, где ни аккаунты, ни Hermes Agent не лежат." >&2
|
||||||
|
echo "" >&2
|
||||||
|
# $0 здесь — распакованная копия во временном каталоге, называть её
|
||||||
|
# владельцу бессмысленно: этого файла через минуту не будет.
|
||||||
|
echo " Запустите тот же установщик от своего имени, без sudo." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Если установка в /root действительно нужна, задайте" >&2
|
||||||
|
echo " HERMES_ALLOW_ROOT=1." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
|
@ -22,6 +43,17 @@ echo "Hermes Home : $HERMES_HOME"
|
||||||
echo "Source Root : $REPO_ROOT"
|
echo "Source Root : $REPO_ROOT"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# 0. Остановка работающего хаба.
|
||||||
|
#
|
||||||
|
# Установщик копировал файлы, но работающий сервер не трогал: он продолжал
|
||||||
|
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
|
||||||
|
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
|
||||||
|
echo "[0/6] Остановка работающего Hermes Hub..."
|
||||||
|
# shellcheck source=./lib_stop_running_hub.sh
|
||||||
|
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
|
||||||
|
stop_running_hub || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
# 1. Check / Discover Python Runtime and Hermes Environment
|
# 1. Check / Discover Python Runtime and Hermes Environment
|
||||||
echo "[1/6] Checking Python and Hermes environment..."
|
echo "[1/6] Checking Python and Hermes environment..."
|
||||||
PYTHON_BIN=""
|
PYTHON_BIN=""
|
||||||
|
|
@ -30,6 +62,8 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
|
||||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
||||||
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
||||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
||||||
|
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
|
||||||
|
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
PYTHON_BIN="$(command -v python3)"
|
PYTHON_BIN="$(command -v python3)"
|
||||||
elif command -v python >/dev/null 2>&1; then
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
|
@ -57,12 +91,48 @@ echo "[2/6] Verifying Python dependencies..."
|
||||||
DEPS_OK=true
|
DEPS_OK=true
|
||||||
"$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false
|
"$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false
|
||||||
|
|
||||||
|
HUB_DEPS="fastapi uvicorn pydantic psutil pyyaml"
|
||||||
|
|
||||||
if [ "$DEPS_OK" != "true" ]; then
|
if [ "$DEPS_OK" != "true" ]; then
|
||||||
echo " Installing required packages (fastapi, uvicorn, pydantic, psutil, pyyaml)..."
|
# Системный python в Debian и Ubuntu помечен как externally-managed
|
||||||
"$PYTHON_BIN" -m pip install --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || {
|
# (PEP 668) и отклоняет pip install — и обычный, и с --user. Обходить это
|
||||||
echo "⚠️ Warning: pip install returned non-zero code. Trying with --user..."
|
# через --break-system-packages нельзя: имя флага не преувеличивает, так
|
||||||
"$PYTHON_BIN" -m pip install --user --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || true
|
# ломают питон всей машины. Правильный ответ — собственный venv.
|
||||||
}
|
NEED_VENV=false
|
||||||
|
if [ -f "/usr/lib/python$PY_VER/EXTERNALLY-MANAGED" ] || [ -f "/usr/lib/python3/EXTERNALLY-MANAGED" ]; then
|
||||||
|
case "$PYTHON_BIN" in
|
||||||
|
*/venv/bin/*) : ;;
|
||||||
|
*) NEED_VENV=true ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_VENV" = "true" ]; then
|
||||||
|
echo " Системный Python защищён от изменений (PEP 668)."
|
||||||
|
echo " Создаю отдельное окружение: $HERMES_HOME/venv"
|
||||||
|
if ! "$PYTHON_BIN" -m venv "$HERMES_HOME/venv" 2>/dev/null; then
|
||||||
|
echo "❌ Не удалось создать виртуальное окружение." >&2
|
||||||
|
echo " Установите пакет python3-venv:" >&2
|
||||||
|
echo " sudo apt install python3-venv" >&2
|
||||||
|
exit 11
|
||||||
|
fi
|
||||||
|
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||||
|
echo " Using Python: $PYTHON_BIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Installing required packages ($HUB_DEPS)..."
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if ! "$PYTHON_BIN" -m pip install --no-warn-script-location -q $HUB_DEPS; then
|
||||||
|
echo "❌ Не удалось установить зависимости через $PYTHON_BIN." >&2
|
||||||
|
echo " Установка прервана: без них хаб не запустится." >&2
|
||||||
|
exit 12
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем результат, а не код возврата pip: установка «прошла», а модуля нет —
|
||||||
|
# именно так предыдущая сборка дошла до проверки и упала на ней.
|
||||||
|
if ! "$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml" >/dev/null 2>&1; then
|
||||||
|
echo "❌ Зависимости не импортируются даже после установки ($PYTHON_BIN)." >&2
|
||||||
|
exit 13
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files)
|
# 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files)
|
||||||
|
|
@ -153,9 +223,35 @@ 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/HermesHub.ico"
|
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/app_icon_256.png"
|
||||||
if [ ! -f "$ICON_PATH" ]; then
|
if [ ! -f "$ICON_PATH" ]; then
|
||||||
ICON_PATH="utilities-terminal"
|
ICON_PATH="utilities-terminal"
|
||||||
fi
|
fi
|
||||||
|
|
@ -176,6 +272,28 @@ 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
|
||||||
|
|
@ -203,5 +321,12 @@ echo "To launch the Web Application window:"
|
||||||
echo " $LAUNCHER_BIN"
|
echo " $LAUNCHER_BIN"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Or open 'Hermes Hub Web' from your Applications menu."
|
echo "Or open 'Hermes Hub Web' from your Applications menu."
|
||||||
|
echo ""
|
||||||
|
echo "ВАЖНО: работавший хаб был остановлен перед установкой, иначе он"
|
||||||
|
echo "продолжал бы выполнять прежний код из памяти. Запустите его заново"
|
||||||
|
echo "командой выше — только тогда новая сборка начнёт работать."
|
||||||
|
echo ""
|
||||||
|
echo "Проверить, что поднялся новый код:"
|
||||||
|
echo " curl -s -D - -o /dev/null http://127.0.0.1:5800/workspace.js | grep -i cache-control"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
54
installer/lib_stop_running_hub.sh
Normal file
54
installer/lib_stop_running_hub.sh
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/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,6 +7,8 @@
|
||||||
|
|
||||||
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}"
|
||||||
|
|
||||||
|
|
@ -23,18 +25,35 @@ 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/3] Removing plugin integration..."
|
echo "[1/4] 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/3] Removing application launchers and desktop entries..."
|
echo "[2/4] 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
|
||||||
|
|
@ -42,17 +61,26 @@ fi
|
||||||
|
|
||||||
# 3. User Data Handling
|
# 3. User Data Handling
|
||||||
if [ "$PURGE_USER_DATA" = "true" ]; then
|
if [ "$PURGE_USER_DATA" = "true" ]; then
|
||||||
echo "[3/3] Purging user data (--purge-user-data specified)..."
|
echo "[3/4] 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/3] Preserving user data and credentials."
|
echo "[3/4] 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.
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
@ -336,7 +336,13 @@ namespace HermesHub
|
||||||
kill.UseShellExecute = false; kill.CreateNoWindow = true;
|
kill.UseShellExecute = false; kill.CreateNoWindow = true;
|
||||||
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
|
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
|
||||||
}
|
}
|
||||||
StopOwnedRuntime(home, false);
|
// Неудачная остановка не повод отменять выход: владелец нажал
|
||||||
|
// «закрыть», и программа обязана закрыться. Прежде окно с
|
||||||
|
// ошибкой возвращало его обратно в работающее приложение.
|
||||||
|
try { StopOwnedRuntime(home, false); }
|
||||||
|
catch (Exception stopEx) {
|
||||||
|
MessageBox.Show("Часть процессов остановить не удалось: " + stopEx.Message + " Hermes Hub закроется; при необходимости снимите их в диспетчере задач.", "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
}
|
||||||
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
|
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
closing = false; timer.Start();
|
closing = false; timer.Start();
|
||||||
|
|
|
||||||
Binary file not shown.
49
launcher/hermes-hub-stop.sh
Normal file
49
launcher/hermes-hub-stop.sh
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -16,6 +16,11 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
|
||||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
|
||||||
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
|
||||||
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python"
|
||||||
|
elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then
|
||||||
|
# Окружение, созданное установщиком, когда системный python закрыт
|
||||||
|
# правилом PEP 668. Без этой ветки запуск уходил бы на /usr/bin/python3,
|
||||||
|
# где зависимостей нет и быть не может.
|
||||||
|
PYTHON_BIN="$HERMES_HOME/venv/bin/python3"
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
PYTHON_BIN="$(command -v python3)"
|
PYTHON_BIN="$(command -v python3)"
|
||||||
elif command -v python >/dev/null 2>&1; then
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hermes-hub"
|
name = "hermes-hub"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
|
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,14 @@ 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()
|
||||||
|
|
@ -204,88 +209,265 @@ 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]:
|
# ═══════════════════════════════════════════════════════════════
|
||||||
"""Live verification of public release feed manifest and package URL."""
|
# Publication Gate
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# Проверка публикации отделена от офлайновой части, потому что раньше они были
|
||||||
|
# смешаны и обе были беззубыми. Измерено на прежней реализации:
|
||||||
|
# - при полном обрыве сети возвращался 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
|
||||||
import urllib.error
|
req = urllib.request.Request(
|
||||||
|
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 host not in allowlist: {DEFAULT_UPDATE_URL}"
|
return False, f"Адрес обновления вне списка разрешённых: {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:
|
||||||
req = urllib.request.Request(
|
with _http_get(DEFAULT_UPDATE_URL) as resp:
|
||||||
DEFAULT_UPDATE_URL,
|
if resp.status != 200:
|
||||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
return verdict(False, f"Манифест релиза ответил HTTP {resp.status}")
|
||||||
)
|
data = json.loads(resp.read().decode("utf-8-sig"))
|
||||||
with urllib.request.urlopen(req, timeout=6) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
data = json.loads(resp.read().decode("utf-8-sig"))
|
|
||||||
p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
|
|
||||||
p_url = data.get("package_url")
|
|
||||||
if not p_url and data.get("assets"):
|
|
||||||
p_url = data["assets"][0].get("browser_download_url")
|
|
||||||
if not p_url:
|
|
||||||
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
|
|
||||||
|
|
||||||
if not p_ver:
|
|
||||||
return False, "Public update manifest is missing version or tag_name"
|
|
||||||
|
|
||||||
# Verify package URL reachability
|
|
||||||
pkg_live = False
|
|
||||||
pkg_status = "UNKNOWN"
|
|
||||||
try:
|
|
||||||
head_req = urllib.request.Request(
|
|
||||||
p_url,
|
|
||||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
|
||||||
)
|
|
||||||
# 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}"
|
|
||||||
|
|
||||||
manifest_live = True
|
|
||||||
package_live = False
|
|
||||||
hash_verified = False
|
|
||||||
|
|
||||||
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, (
|
|
||||||
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
|
||||||
f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed."
|
|
||||||
)
|
|
||||||
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:
|
except urllib.error.HTTPError as he:
|
||||||
if he.code == 404:
|
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
|
||||||
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:
|
except Exception as exc:
|
||||||
return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed."
|
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
return True, "Production update feed verified"
|
version = data.get("version") or str(data.get("tag_name", "")).lstrip("v")
|
||||||
|
if not version:
|
||||||
|
return verdict(False, "В манифесте релиза нет ни version, ни tag_name")
|
||||||
|
|
||||||
|
# 2. Ассеты
|
||||||
|
assets: dict[str, str] = {}
|
||||||
|
for asset in data.get("assets") or []:
|
||||||
|
name = asset.get("name")
|
||||||
|
url = asset.get("browser_download_url")
|
||||||
|
if name and url:
|
||||||
|
assets[name] = url
|
||||||
|
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)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Опубликованные контрольные суммы
|
||||||
|
if CHECKSUMS_ASSET_NAME not in assets:
|
||||||
|
return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем")
|
||||||
|
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} байт)")
|
||||||
|
|
||||||
|
return True, (
|
||||||
|
f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] "
|
||||||
|
f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME} — "
|
||||||
|
+ ", ".join(verified)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]:
|
||||||
|
"""Собранный набор ассетов действительно устанавливается обновлением.
|
||||||
|
|
||||||
|
Проверяется до публикации. Причина: 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 = [
|
||||||
|
|
@ -295,7 +477,8 @@ 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. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed),
|
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
|
||||||
|
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
|
||||||
]
|
]
|
||||||
|
|
||||||
all_passed = True
|
all_passed = True
|
||||||
|
|
@ -319,5 +502,24 @@ 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__":
|
||||||
run_release_gate()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ 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,
|
||||||
|
|
@ -138,10 +145,21 @@ def run_checks() -> int:
|
||||||
|
|
||||||
# 7. Antigravity profile isolation
|
# 7. Antigravity profile isolation
|
||||||
print("7. Checking Antigravity profile environment directory isolation...")
|
print("7. Checking Antigravity profile environment directory isolation...")
|
||||||
pdir = get_profile_env_dir("ag-w2")
|
# Проверяем изоляцию пути, а не побочное создание каталога: запрос пути
|
||||||
assert pdir.exists()
|
# каталогов больше не плодит, иначе любая проверка засоряла бы диск
|
||||||
assert "ag-w2" in str(pdir)
|
# десятком пустых слотов.
|
||||||
print(f" [PASS] Profile directory isolated at {pdir}")
|
#
|
||||||
|
# ID заведомо не боевой. Было "ag-w2" — на живой машине владельца это
|
||||||
|
# существующий подключённый профиль, и "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 not pdir.exists(), "запрос пути не должен создавать каталог"
|
||||||
|
print(f" [PASS] Profile directory isolated at {pdir} (не создан)")
|
||||||
passed += 1
|
passed += 1
|
||||||
|
|
||||||
# 8. Error classification
|
# 8. Error classification
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
48
src/antigravity_provider/console_encoding.py
Normal file
48
src/antigravity_provider/console_encoding.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Принудительный 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)
|
||||||
|
|
@ -111,8 +111,20 @@ def get_compatibility_path() -> Path:
|
||||||
return get_config_dir() / "compatibility.json"
|
return get_config_dir() / "compatibility.json"
|
||||||
|
|
||||||
|
|
||||||
def get_profile_dir(profile_id: str, provider: Optional[str] = None) -> Path:
|
def get_profile_dir(
|
||||||
"""Return isolated storage directory for a profile.
|
profile_id: str, provider: Optional[str] = None, create: bool = False
|
||||||
|
) -> Path:
|
||||||
|
"""Вернуть каталог профиля. По умолчанию НИЧЕГО не создаёт.
|
||||||
|
|
||||||
|
Раньше эта функция создавала каталог при каждом обращении. В коде зашит
|
||||||
|
список «стандартных» слотов — ag-orch-primary, ag-orch-fallback, ag-1..ag-20,
|
||||||
|
ag-w1..ag-w10, — и любой обход этого списка материализовал их на диске. У
|
||||||
|
владельца накопилось 25 каталогов, из которых он заводил единицы: остальные
|
||||||
|
появились сами, просто потому что кто-то спросил путь.
|
||||||
|
|
||||||
|
Спрашивать, где профиль жил бы, и создавать его — разные действия. Создание
|
||||||
|
теперь запрашивается явно через create=True: так делают те, кто
|
||||||
|
действительно заводит профиль.
|
||||||
|
|
||||||
Accepts both (profile_id) and (provider, profile_id) or (profile_id, provider) gracefully.
|
Accepts both (profile_id) and (provider, profile_id) or (profile_id, provider) gracefully.
|
||||||
"""
|
"""
|
||||||
|
|
@ -151,7 +163,8 @@ def get_profile_dir(profile_id: str, provider: Optional[str] = None) -> Path:
|
||||||
folder_prefix = f"{prov_lower}_profiles"
|
folder_prefix = f"{prov_lower}_profiles"
|
||||||
|
|
||||||
d = get_hermes_home() / folder_prefix / p_id
|
d = get_hermes_home() / folder_prefix / p_id
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
if create:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Tuple, Optional, Callable, List
|
from typing import Any, Dict, Tuple, Optional, Callable, List
|
||||||
|
|
||||||
|
|
@ -51,12 +52,12 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
|
||||||
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
|
return {'success': False, 'error': f"Профиль '{profile_id}' не найден"}
|
||||||
|
|
||||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
status = ProfileAuthManager.get_profile_status(pcfg.provider, profile_id)
|
||||||
if not status.get('authenticated'):
|
|
||||||
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
|
|
||||||
|
|
||||||
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
|
||||||
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
|
||||||
|
|
||||||
|
if not status.get('authenticated'):
|
||||||
|
return {'success': False, 'error': 'Аккаунт не добавлен. Сначала выполните подключение.'}
|
||||||
|
|
||||||
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
|
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
|
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
|
||||||
|
|
@ -142,6 +143,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system")
|
||||||
try:
|
try:
|
||||||
auth_p.unlink()
|
auth_p.unlink()
|
||||||
from .state_store import HubStateStore
|
from .state_store import HubStateStore
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
AccountQuotaService.get().forget_profile(provider, profile_id)
|
||||||
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
|
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
|
||||||
EventLogService.get().log(
|
EventLogService.get().log(
|
||||||
'account',
|
'account',
|
||||||
|
|
@ -705,6 +708,50 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
return {'ok': False, 'message': reason, 'data': {'status': status}}
|
||||||
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
|
||||||
|
|
||||||
|
if action == 'probe_account_models':
|
||||||
|
# Определение доступных аккаунту моделей. Запускается только по
|
||||||
|
# явному действию владельца: каталог NVIDIA публичный и о правах
|
||||||
|
# аккаунта ничего не сообщает, поэтому доступность выясняется
|
||||||
|
# опросом, а он тратит вызовы и упирается в ограничения частоты.
|
||||||
|
from .model_entitlements import probe_account_models, load_entitlements
|
||||||
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
|
||||||
|
pid = data.get('profile_id') or ''
|
||||||
|
prov_norm = (prov or data.get('provider') or '').strip().lower()
|
||||||
|
if not pid or not prov_norm:
|
||||||
|
return {'ok': False, 'message': 'Не указан профиль или провайдер'}
|
||||||
|
|
||||||
|
if data.get('cached_only'):
|
||||||
|
cached = load_entitlements(prov_norm, pid)
|
||||||
|
if not cached:
|
||||||
|
return {'ok': True, 'message': 'Н/Д: доступность моделей ещё не определялась', 'data': {}}
|
||||||
|
return {'ok': True, 'message': 'Сохранённый результат', 'data': cached}
|
||||||
|
|
||||||
|
auth = ProfileAuthManager.load_profile_auth(prov_norm, pid) or {}
|
||||||
|
token = (auth.get('api_key') or auth.get('token') or '').strip()
|
||||||
|
if not token:
|
||||||
|
return {'ok': False, 'message': f'У профиля {pid} нет сохранённого API-ключа'}
|
||||||
|
|
||||||
|
pcfg = load_router_config().get_profile(pid)
|
||||||
|
base_url = (getattr(pcfg, 'custom_base_url', None) or auth.get('base_url') or '').strip()
|
||||||
|
if not base_url:
|
||||||
|
base_url = 'https://integrate.api.nvidia.com/v1' if prov_norm.startswith('nvidia') else ''
|
||||||
|
if not base_url:
|
||||||
|
return {'ok': False, 'message': f'Не известен адрес провайдера для {pid}'}
|
||||||
|
|
||||||
|
models = ModelDiscoveryService.get().get_models(prov_norm) or []
|
||||||
|
if not models:
|
||||||
|
return {'ok': False, 'message': 'Каталог моделей ещё не получен — сначала запросите список'}
|
||||||
|
|
||||||
|
res = probe_account_models(prov_norm, pid, token, base_url, models)
|
||||||
|
d = res.to_dict()
|
||||||
|
msg = (
|
||||||
|
f"Доступно {len(res.available)} из {d['total']}; "
|
||||||
|
f"не выдано {len(res.unavailable)}; "
|
||||||
|
f"не определено {len(res.undetermined)}"
|
||||||
|
)
|
||||||
|
return {'ok': True, 'message': msg, 'data': d}
|
||||||
|
|
||||||
if action == 'validate_connection':
|
if action == 'validate_connection':
|
||||||
from .connection_preflight import validate_connection
|
from .connection_preflight import validate_connection
|
||||||
return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')
|
return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')
|
||||||
|
|
@ -739,8 +786,20 @@ class ActionExecutor:
|
||||||
valid, reason = AutoAssigner.validate_slot(prov_norm, slot)
|
valid, reason = AutoAssigner.validate_slot(prov_norm, slot)
|
||||||
if not valid:
|
if not valid:
|
||||||
return {'ok': False, 'message': reason}
|
return {'ok': False, 'message': reason}
|
||||||
|
# Поддержку провайдера проверяем ДО требования ключа: у чужого
|
||||||
|
# провайдера ключ не поможет, и сообщение «не указан API-ключ»
|
||||||
|
# уводит владельца не туда.
|
||||||
|
_SUPPORTED_DIRECT_ADD = (
|
||||||
|
'openrouter', 'nvidia', 'claude', 'anthropic', 'opencode-go', 'opencode',
|
||||||
|
'openai-codex', 'codex', 'grok', 'xai', 'antigravity', 'google-antigravity',
|
||||||
|
'local', 'local-llm', 'llama.cpp', 'ollama', 'vllm',
|
||||||
|
)
|
||||||
|
if prov_norm not in _SUPPORTED_DIRECT_ADD:
|
||||||
|
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||||
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {}
|
existing_status = ProfileAuthManager.get_profile_status(prov_norm, slot) if slot else {}
|
||||||
if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'):
|
if not token and prov_norm not in ('local', 'vllm', 'ollama') and not existing_status.get('authenticated'):
|
||||||
|
if prov_norm in ('antigravity', 'google-antigravity', 'claude', 'anthropic'):
|
||||||
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
|
return {'ok': False, 'message': 'Не указан API-ключ или не завершена авторизация'}
|
||||||
validation = None
|
validation = None
|
||||||
if token or prov_norm in ('local', 'vllm', 'ollama'):
|
if token or prov_norm in ('local', 'vllm', 'ollama'):
|
||||||
|
|
@ -778,7 +837,7 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
return {'ok': False, 'message': 'Не указан API-ключ для NVIDIA NIM'}
|
||||||
elif prov_norm in ('claude', 'anthropic'):
|
elif prov_norm in ('claude', 'anthropic'):
|
||||||
if not token:
|
if not token:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для Claude'}
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
elif prov_norm in ('opencode-go', 'opencode'):
|
elif prov_norm in ('opencode-go', 'opencode'):
|
||||||
if not token:
|
if not token:
|
||||||
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
return {'ok': False, 'message': 'Не указан API-ключ для OpenCode Go'}
|
||||||
|
|
@ -790,7 +849,7 @@ class ActionExecutor:
|
||||||
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
return {'ok': False, 'message': f'Не указан API-ключ для {prov_norm}'}
|
||||||
elif prov_norm in ('antigravity', 'google-antigravity'):
|
elif prov_norm in ('antigravity', 'google-antigravity'):
|
||||||
if not token and not is_authenticated:
|
if not token and not is_authenticated:
|
||||||
return {'ok': False, 'message': 'Не выполнена авторизация для Antigravity'}
|
return {'ok': False, 'message': 'Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата.'}
|
||||||
else:
|
else:
|
||||||
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
return {'ok': False, 'message': f'Провайдер {prov_norm} не поддерживается для прямого добавления учетных данных'}
|
||||||
|
|
||||||
|
|
@ -825,6 +884,10 @@ class ActionExecutor:
|
||||||
# Повторный вызов здесь обращался к auth_data, которой у уже
|
# Повторный вызов здесь обращался к auth_data, которой у уже
|
||||||
# авторизованного аккаунта не существует: перевод аккаунта в
|
# авторизованного аккаунта не существует: перевод аккаунта в
|
||||||
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
|
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
|
||||||
|
# Слот мог принадлежать другому аккаунту: старое опознание
|
||||||
|
# обязано уйти вместе с прежними учётными данными.
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
AccountQuotaService.get().forget_profile(prov_norm, slot)
|
||||||
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
|
||||||
if validation:
|
if validation:
|
||||||
from .model_discovery_service import ModelDiscoveryService
|
from .model_discovery_service import ModelDiscoveryService
|
||||||
|
|
@ -838,7 +901,27 @@ class ActionExecutor:
|
||||||
if validation:
|
if validation:
|
||||||
AccountProbeService.get().record_validation(prov_norm, slot, validation)
|
AccountProbeService.get().record_validation(prov_norm, slot, validation)
|
||||||
return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}}
|
return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}}
|
||||||
result = AccountProbeService.get().check_now(prov_norm, slot)
|
# Проверку у провайдера не ждём в самом действии. Для
|
||||||
|
# Antigravity она идёт через CLI и в худшем случае занимает до
|
||||||
|
# 90 с на захват замка, до 65 на каталог моделей и до 90 на
|
||||||
|
# пробный вызов — около четырёх минут молчания при обещанной
|
||||||
|
# «минуте на этап». Владелец видит это как зависший мастер.
|
||||||
|
#
|
||||||
|
# Провайдеры с ключом сюда не попадают: их подключение уже
|
||||||
|
# проверено предварительной проверкой выше и возвращается
|
||||||
|
# сразу, как того требует A54.
|
||||||
|
probe = AccountProbeService.get()
|
||||||
|
started = probe.schedule(prov_norm, slot, force=True)
|
||||||
|
if started or probe.state(slot).get('state') == 'checking':
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Аккаунт подключён. Проверка у провайдера идёт в фоне, '
|
||||||
|
'результат появится в карточке.',
|
||||||
|
'data': {'profile_id': slot, 'check': 'running'},
|
||||||
|
}
|
||||||
|
# Фоновая служба не работает — проверяем здесь, иначе результата
|
||||||
|
# не будет вовсе. Ручная проверка обязана работать и без неё.
|
||||||
|
result = probe.check_now(prov_norm, slot)
|
||||||
result.setdefault('data', {})['profile_id'] = slot
|
result.setdefault('data', {})['profile_id'] = slot
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -848,7 +931,52 @@ class ActionExecutor:
|
||||||
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
|
# A25 внёс их в этот список, но в A24 они выполняют настоящую работу —
|
||||||
# сохранение цепочки и назначение роли — обработчики ниже. Проглотив их
|
# сохранение цепочки и назначение роли — обработчики ниже. Проглотив их
|
||||||
# здесь, мы бы молча сломали перестановку блоков в маршрутизации.
|
# здесь, мы бы молча сломали перестановку блоков в маршрутизации.
|
||||||
# ── Вход по localhost-redirect (Antigravity) ──────────────────
|
# ── Вход через CLI в терминале (Antigravity native login, A57) ──
|
||||||
|
if action in ('start_native_auth', 'start_native_agy_login', 'start_terminal_auth'):
|
||||||
|
provider = (data.get('provider') or '').strip().lower()
|
||||||
|
if provider in ('google-antigravity', 'agy'):
|
||||||
|
provider = 'antigravity'
|
||||||
|
if provider != 'antigravity':
|
||||||
|
return {'ok': False, 'message': f'Провайдер {provider} не поддерживает вход через agy CLI'}
|
||||||
|
|
||||||
|
slot = data.get('profile_id')
|
||||||
|
force = bool(data.get('force') or data.get('confirmed') or data.get('overwrite'))
|
||||||
|
from antigravity_provider.agy_subprocess import start_native_agy_login
|
||||||
|
|
||||||
|
ok, msg, res_data = start_native_agy_login(profile_id=slot, force=force)
|
||||||
|
return {'ok': ok, 'message': msg, 'data': res_data}
|
||||||
|
|
||||||
|
if action in ('poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth'):
|
||||||
|
session_id = data.get('session_id') or ''
|
||||||
|
from antigravity_provider.agy_subprocess import poll_native_agy_login
|
||||||
|
|
||||||
|
ok, msg, res_data = poll_native_agy_login(session_id)
|
||||||
|
if ok and res_data.get('status') == 'completed':
|
||||||
|
slot = 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}
|
||||||
|
|
||||||
|
if action in ('cancel_native_auth', 'cancel_native_agy_login', 'cancel_terminal_auth'):
|
||||||
|
session_id = data.get('session_id') or ''
|
||||||
|
from antigravity_provider.agy_subprocess import cancel_native_agy_login
|
||||||
|
|
||||||
|
ok, msg = cancel_native_agy_login(session_id)
|
||||||
|
return {'ok': ok, 'message': msg}
|
||||||
|
|
||||||
|
# ── Вход по localhost-redirect (Antigravity / Claude запасной путь) ──
|
||||||
# Раньше веб-мастер писал «авторизация через веб-интерфейс
|
# Раньше веб-мастер писал «авторизация через веб-интерфейс
|
||||||
# невозможна» и отправлял в консоль по SSH. Это неверно:
|
# невозможна» и отправлял в консоль по SSH. Это неверно:
|
||||||
# ProfileOAuthSession.handle_manual_callback_url принимает адрес
|
# ProfileOAuthSession.handle_manual_callback_url принимает адрес
|
||||||
|
|
@ -1236,6 +1364,22 @@ 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()
|
||||||
|
|
@ -1317,5 +1461,141 @@ class ActionExecutor:
|
||||||
res = setup_memory_structure(vault_path=v_path, project_name=p_name)
|
res = setup_memory_structure(vault_path=v_path, project_name=p_name)
|
||||||
return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res}
|
return {'ok': res.get('ok', False), 'message': res.get('message', ''), 'data': res}
|
||||||
|
|
||||||
|
elif action == 'get_compression_status':
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
|
||||||
|
hub_settings = get_hub_settings()
|
||||||
|
c_pid = hub_settings.get('compressor_profile_id')
|
||||||
|
c_pcfg = None
|
||||||
|
if c_pid:
|
||||||
|
try:
|
||||||
|
c_pcfg = load_router_config().get_profile(c_pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
supervisor = LocalSupervisor()
|
||||||
|
status_data = supervisor.get_compression_status(c_pcfg)
|
||||||
|
status_data['threshold_percent'] = hub_settings.get('compression_threshold_percent', 75.0)
|
||||||
|
status_data['keep_recent_messages'] = hub_settings.get('compression_keep_recent_messages', 3)
|
||||||
|
status_data['compression_enabled'] = hub_settings.get('compression_enabled', True)
|
||||||
|
return {'ok': True, 'message': 'Статус сжатия получен', 'data': status_data}
|
||||||
|
|
||||||
|
elif action == 'get_compression_history':
|
||||||
|
from antigravity_provider.router.context_compressor import ContextCompressor
|
||||||
|
history = ContextCompressor().get_compression_history(limit=int(data.get('limit', 20)))
|
||||||
|
return {'ok': True, 'message': f'Получено записей: {len(history)}', 'data': {'history': history}}
|
||||||
|
|
||||||
|
elif action == 'test_compression':
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
|
||||||
|
hub_settings = get_hub_settings()
|
||||||
|
c_pid = data.get('profile_id') or hub_settings.get('compressor_profile_id')
|
||||||
|
c_pcfg = None
|
||||||
|
if c_pid and c_pid != 'none':
|
||||||
|
try:
|
||||||
|
c_pcfg = load_router_config().get_profile(c_pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
test_messages = [
|
||||||
|
{"role": "system", "content": "You are a software engineer."},
|
||||||
|
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. The primary coder is on port 8081 with 224K context (229376 tokens), generating at 107.4 tok/s. VRAM usage: 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082."},
|
||||||
|
{"role": "assistant", "content": "Acknowledged. All server metrics and ports are noted."},
|
||||||
|
{"role": "user", "content": "Now run preflight diagnostics for LocalSupervisor and ContextCompressor in src/antigravity_provider/router/local_supervisor.py."},
|
||||||
|
{"role": "assistant", "content": "Diagnostics completed successfully. Memory vault is at /srv/projects/AI-Memory."},
|
||||||
|
{"role": "user", "content": "What is our current task?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
supervisor = LocalSupervisor()
|
||||||
|
new_msgs, outcome = supervisor.compress_context_if_needed(
|
||||||
|
messages=test_messages,
|
||||||
|
target_context_limit=32768,
|
||||||
|
compressor_profile=c_pcfg,
|
||||||
|
threshold_percent=0.0, # force compression
|
||||||
|
keep_recent_messages=2,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ok': outcome.status == 'SUCCESS',
|
||||||
|
'message': outcome.status_message,
|
||||||
|
'data': {
|
||||||
|
'outcome': asdict(outcome) if hasattr(outcome, '__dataclass_fields__') else outcome.__dict__,
|
||||||
|
'messages_before_count': len(test_messages),
|
||||||
|
'messages_after_count': len(new_msgs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Проверка доступности agy и сценарии владельца (A58) ──
|
||||||
|
elif action == 'refresh_agy_eligibility':
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
service.invalidate_cache()
|
||||||
|
state = service.check_eligibility_state(force=True)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': f"Состояние проверки agy: {state.get('status_label_ru')}",
|
||||||
|
'data': state,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action == 'run_agy_patch_script':
|
||||||
|
from pathlib import Path
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
|
from antigravity_provider.agy_subprocess import launch_terminal_task
|
||||||
|
|
||||||
|
settings = get_hub_settings()
|
||||||
|
raw_path = str(settings.get('agy_patch_script_path') or '').strip()
|
||||||
|
if not raw_path:
|
||||||
|
return {'ok': False, 'message': 'Н/Д: путь к сценарию патча не указан в настройках'}
|
||||||
|
|
||||||
|
p = Path(raw_path).expanduser().resolve()
|
||||||
|
if not p.is_file() or not (os.access(p, os.X_OK) or p.suffix in ('.sh', '.py', '.bat', '.cmd', '.exe')):
|
||||||
|
return {'ok': False, 'message': f'Файл сценария не найден или недоступен: {raw_path}'}
|
||||||
|
|
||||||
|
ok, msg, res_data = launch_terminal_task(
|
||||||
|
title='Antigravity Eligibility Patch',
|
||||||
|
command_args=[str(p)],
|
||||||
|
work_dir=p.parent,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'message': f'Не удалось запустить сценарий в терминале: {msg}', 'data': res_data}
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
service.invalidate_cache()
|
||||||
|
new_state = service.check_eligibility_state(force=True)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Сценарий патча запущен в окне терминала',
|
||||||
|
'data': {'eligibility': new_state, 'terminal': res_data},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif action == 'run_agy_update':
|
||||||
|
from antigravity_provider.agy_subprocess import get_agy_exe, launch_terminal_task
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
|
from antigravity_provider.paths import get_hermes_home
|
||||||
|
|
||||||
|
try:
|
||||||
|
agy_exe = get_agy_exe()
|
||||||
|
except Exception as exc:
|
||||||
|
return {'ok': False, 'message': f'Утилита agy не найдена: {exc}'}
|
||||||
|
|
||||||
|
ok, msg, res_data = launch_terminal_task(
|
||||||
|
title='Обновление Antigravity CLI (agy update)',
|
||||||
|
command_args=[agy_exe, 'update'],
|
||||||
|
work_dir=get_hermes_home(),
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return {'ok': False, 'message': f'Не удалось запустить обновление в терминале: {msg}', 'data': res_data}
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
service.invalidate_cache()
|
||||||
|
new_state = service.check_eligibility_state(force=True)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Обновление agy запущено в окне терминала',
|
||||||
|
'data': {'eligibility': new_state, 'terminal': res_data},
|
||||||
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}
|
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,21 @@ class AntigravityAdapter(BaseProviderAdapter):
|
||||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
profile_dir = get_profile_env_dir(profile.profile_id)
|
profile_dir = get_profile_env_dir(profile.profile_id)
|
||||||
# Isolate USERPROFILE and HOME while strictly stripping non-Antigravity provider secrets
|
# Isolate USERPROFILE and HOME while strictly stripping non-Antigravity provider secrets
|
||||||
custom_env = build_safe_subprocess_env(
|
overrides = {
|
||||||
overrides={
|
"USERPROFILE": str(profile_dir),
|
||||||
"USERPROFILE": str(profile_dir),
|
"HOME": str(profile_dir),
|
||||||
"HOME": str(profile_dir),
|
"HOMEPATH": str(profile_dir),
|
||||||
"HOMEPATH": str(profile_dir),
|
}
|
||||||
}
|
# Тот же прокси, что и при входе: иначе аккаунт подключён, а запросы
|
||||||
|
# упираются в проверку доступности по стране.
|
||||||
|
from antigravity_provider.agy_subprocess import (
|
||||||
|
proxy_env_overrides,
|
||||||
|
resolve_provider_proxy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
overrides.update(proxy_env_overrides(resolve_provider_proxy(profile.profile_id)))
|
||||||
|
custom_env = build_safe_subprocess_env(overrides=overrides)
|
||||||
|
|
||||||
# If profile specifies a preferred model and request has generic or no model
|
# If profile specifies a preferred model and request has generic or no model
|
||||||
req = dict(request)
|
req = dict(request)
|
||||||
model = req.get("model", "")
|
model = req.get("model", "")
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,36 @@ class LocalLLMAdapter(BaseProviderAdapter):
|
||||||
|
|
||||||
messages = list(request.get("messages", []))
|
messages = list(request.get("messages", []))
|
||||||
|
|
||||||
# Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow
|
# Context Compression & Truncation Guard
|
||||||
context_window = self.get_context_window(profile, model, query_remote=False)
|
context_window = self.get_context_window(profile, model, query_remote=False)
|
||||||
if context_window is not None and context_window > 0 and len(messages) > 1:
|
if context_window is not None and context_window > 0 and len(messages) > 1:
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
|
hub_settings = get_hub_settings()
|
||||||
|
threshold_pct = float(hub_settings.get("compression_threshold_percent", 75.0))
|
||||||
|
keep_recent = int(hub_settings.get("compression_keep_recent_messages", 3))
|
||||||
|
compressor_pid = hub_settings.get("compressor_profile_id")
|
||||||
|
|
||||||
|
compressor_pconfig = None
|
||||||
|
if compressor_pid:
|
||||||
|
try:
|
||||||
|
rcfg = load_router_config()
|
||||||
|
compressor_pconfig = rcfg.get_profile(compressor_pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
supervisor = LocalSupervisor(base_url=base_url)
|
||||||
|
messages, outcome = supervisor.compress_context_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=context_window,
|
||||||
|
compressor_profile=compressor_pconfig,
|
||||||
|
threshold_percent=threshold_pct,
|
||||||
|
keep_recent_messages=keep_recent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Secondary Safety Guard: if still exceeding token budget (e.g. huge single message or compressor disabled/failed)
|
||||||
max_tok = int(request.get("max_tokens", 0) or 0)
|
max_tok = int(request.get("max_tokens", 0) or 0)
|
||||||
token_budget = context_window - max_tok - 64
|
token_budget = context_window - max_tok - 64
|
||||||
if token_budget > 100:
|
if token_budget > 100:
|
||||||
|
|
@ -156,7 +183,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
|
||||||
|
|
||||||
if _est_tok(messages) > token_budget:
|
if _est_tok(messages) > token_budget:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.",
|
"Context safety boundary active for %s: prompt exceeds token budget (%d). Truncating middle messages.",
|
||||||
profile.profile_id,
|
profile.profile_id,
|
||||||
context_window,
|
context_window,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,20 @@ class NvidiaAdapter(BaseProviderAdapter):
|
||||||
if key:
|
if key:
|
||||||
return str(key).strip()
|
return str(key).strip()
|
||||||
|
|
||||||
|
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
|
||||||
|
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
|
||||||
|
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
|
||||||
|
# authorization was missing», хотя список моделей тем же ключом получался.
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||||
|
key = stored.get("api_key") or stored.get("token")
|
||||||
|
if key:
|
||||||
|
return str(key).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
suffix = profile.profile_id.upper().replace("-", "_")
|
suffix = profile.profile_id.upper().replace("-", "_")
|
||||||
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
|
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
|
||||||
val = os.environ.get(candidate, "").strip()
|
val = os.environ.get(candidate, "").strip()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ from .local_adapter import LocalLLMAdapter
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.router.adapter.ollama")
|
logger = logging.getLogger("hermes.router.adapter.ollama")
|
||||||
|
|
||||||
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"
|
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
||||||
|
CLOUD_OLLAMA_BASE_URL = "https://ollama.com"
|
||||||
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
|
DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter):
|
||||||
and native Ollama endpoints (/api/tags).
|
and native Ollama endpoints (/api/tags).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _stored_base_url(self, profile: RouterProfileConfig) -> str:
|
||||||
|
"""Адрес из хранилища учётных данных, если владелец его задавал."""
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||||
|
return str(stored.get("base_url") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
|
def _resolve_base_url(self, profile: RouterProfileConfig) -> str:
|
||||||
"""Resolve base_url from profile custom_base_url, auth_config, or environment."""
|
"""Адрес сервера: локальный либо облачный.
|
||||||
url = (
|
|
||||||
|
У аккаунта Ollama Cloud локального сервера нет вовсе — только ключ.
|
||||||
|
Раньше адаптер всегда шёл на 127.0.0.1:11434, получал «Connection
|
||||||
|
refused» и объявлял облачный аккаунт неработающим, хотя каталог из
|
||||||
|
девятнадцати моделей у него получался.
|
||||||
|
"""
|
||||||
|
explicit = (
|
||||||
profile.custom_base_url
|
profile.custom_base_url
|
||||||
or profile.auth_config.get("base_url")
|
or profile.auth_config.get("base_url")
|
||||||
|
or self._stored_base_url(profile)
|
||||||
|
)
|
||||||
|
if not explicit and self._resolve_api_key(profile):
|
||||||
|
return CLOUD_OLLAMA_BASE_URL
|
||||||
|
|
||||||
|
url = (
|
||||||
|
explicit
|
||||||
or os.environ.get("OLLAMA_BASE_URL")
|
or os.environ.get("OLLAMA_BASE_URL")
|
||||||
or os.environ.get("OLLAMA_HOST")
|
or os.environ.get("OLLAMA_HOST")
|
||||||
or DEFAULT_OLLAMA_BASE_URL
|
or DEFAULT_OLLAMA_BASE_URL
|
||||||
|
|
@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter):
|
||||||
if key:
|
if key:
|
||||||
return str(key).strip()
|
return str(key).strip()
|
||||||
|
|
||||||
|
# Ключ, введённый в мастере, лежит в хранилище учётных данных, а не в
|
||||||
|
# auth_config из router_profiles.yaml.
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||||
|
key = stored.get("api_key") or stored.get("token")
|
||||||
|
if key:
|
||||||
|
return str(key).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
suffix = profile.profile_id.upper().replace("-", "_")
|
suffix = profile.profile_id.upper().replace("-", "_")
|
||||||
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
|
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
|
||||||
val = os.environ.get(candidate, "").strip()
|
val = os.environ.get(candidate, "").strip()
|
||||||
|
|
@ -54,21 +90,24 @@ class OllamaAdapter(LocalLLMAdapter):
|
||||||
|
|
||||||
def _get_native_host(self, base_url: str) -> str:
|
def _get_native_host(self, base_url: str) -> str:
|
||||||
"""Strip trailing /v1 from base_url to get native Ollama host."""
|
"""Strip trailing /v1 from base_url to get native Ollama host."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return base_url[:-3]
|
if b.endswith("/v1"):
|
||||||
return base_url
|
return b[:-3].rstrip("/")
|
||||||
|
return b
|
||||||
|
|
||||||
def _get_chat_url(self, base_url: str) -> str:
|
def _get_chat_url(self, base_url: str) -> str:
|
||||||
"""Get standard chat completions endpoint URL."""
|
"""Get standard chat completions endpoint URL."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return f"{base_url}/chat/completions"
|
if b.endswith("/v1"):
|
||||||
return f"{base_url}/v1/chat/completions"
|
return f"{b}/chat/completions"
|
||||||
|
return f"{b}/v1/chat/completions"
|
||||||
|
|
||||||
def _get_models_url(self, base_url: str) -> str:
|
def _get_models_url(self, base_url: str) -> str:
|
||||||
"""Get OpenAI-compatible models endpoint URL."""
|
"""Get OpenAI-compatible models endpoint URL."""
|
||||||
if base_url.endswith("/v1"):
|
b = base_url.rstrip("/")
|
||||||
return f"{base_url}/models"
|
if b.endswith("/v1"):
|
||||||
return f"{base_url}/v1/models"
|
return f"{b}/models"
|
||||||
|
return f"{b}/v1/models"
|
||||||
|
|
||||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
base_url = self._resolve_base_url(profile)
|
base_url = self._resolve_base_url(profile)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,20 @@ class OpenRouterAdapter(BaseProviderAdapter):
|
||||||
if key:
|
if key:
|
||||||
return str(key).strip()
|
return str(key).strip()
|
||||||
|
|
||||||
|
# Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в
|
||||||
|
# auth_config из router_profiles.yaml. Адаптер читал только второе место и
|
||||||
|
# уходил без заголовка Authorization: провайдер отвечал 401 «Header of type
|
||||||
|
# authorization was missing», хотя список моделей тем же ключом получался.
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {}
|
||||||
|
key = stored.get("api_key") or stored.get("token")
|
||||||
|
if key:
|
||||||
|
return str(key).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
suffix = profile.profile_id.upper().replace("-", "_")
|
suffix = profile.profile_id.upper().replace("-", "_")
|
||||||
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
|
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
|
||||||
val = os.environ.get(candidate, "").strip()
|
val = os.environ.get(candidate, "").strip()
|
||||||
|
|
|
||||||
329
src/antigravity_provider/router/agy_eligibility_service.py
Normal file
329
src/antigravity_provider/router/agy_eligibility_service.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
"""Hermes Hub — Antigravity CLI (agy) Eligibility State Detection Service.
|
||||||
|
|
||||||
|
Provides 100% read-only analysis of the agy binary machine code to determine whether
|
||||||
|
the region eligibility check is active, removed (patched by owner), or undetermined.
|
||||||
|
Strictly NEVER modifies or writes to executable files, and NEVER downloads external code.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from antigravity_provider.agy_subprocess import (
|
||||||
|
build_safe_subprocess_env,
|
||||||
|
get_agy_exe,
|
||||||
|
hidden_process_kwargs,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.event_bus import (
|
||||||
|
EVENT_AGY_ELIGIBILITY_CHANGED,
|
||||||
|
EventBus,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.agy_eligibility")
|
||||||
|
|
||||||
|
STATUS_CHECK_REMOVED = "check_removed"
|
||||||
|
STATUS_CHECK_ACTIVE = "check_active"
|
||||||
|
STATUS_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
# ── Machine Code Signatures ──────────────────────────────────────────
|
||||||
|
# x86-64 Original:
|
||||||
|
# test rax,rax ; je eligible ; cmp byte[rax+8],0 ; jne eligible ; call failure
|
||||||
|
# cmp byte[rax+8], 0 is \x80\x78\x08\x00
|
||||||
|
X86_ORIG_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x80\x78\x08\x00\x75.", re.DOTALL)
|
||||||
|
X86_ORIG_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x80\x78\x08\x00\x0f\x85....", re.DOTALL)
|
||||||
|
X86_ORIG_STANDALONE = re.compile(rb"\x80\x78\x08\x00", re.DOTALL)
|
||||||
|
|
||||||
|
# x86-64 Patched:
|
||||||
|
# test rax,rax ; je eligible ; test rax,rax ; nop ; jne eligible
|
||||||
|
# test rax,rax ; nop is \x48\x85\xc0\x90
|
||||||
|
X86_PATCH_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x48\x85\xc0\x90\x75.", re.DOTALL)
|
||||||
|
X86_PATCH_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x48\x85\xc0\x90\x0f\x85....", re.DOTALL)
|
||||||
|
|
||||||
|
# arm64 Original & Patched signatures
|
||||||
|
# In Go arm64, struct field [X0, #8] access followed by conditional branch:
|
||||||
|
# LDRB Wn, [X0, #8] -> \x0n\x20\x40\x39
|
||||||
|
ARM64_ORIG_PATTERN = re.compile(rb"[\x00-\x1f]\x20\x40\x39", re.DOTALL)
|
||||||
|
# Patched replaces LDRB / branch with NOP (\x1f\x20\x03\xd5) or MOV X0, X0 (\xe0\x03\x00\xaa)
|
||||||
|
ARM64_PATCH_PATTERN = re.compile(rb"\x1f\x20\x03\xd5", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
class AgyEligibilityService:
|
||||||
|
"""Thread-safe, read-only analyzer of agy CLI binary eligibility check status."""
|
||||||
|
|
||||||
|
_instance: Optional[AgyEligibilityService] = None
|
||||||
|
_instance_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._cached_state: Optional[Dict[str, Any]] = None
|
||||||
|
self._last_binary_path: Optional[str] = None
|
||||||
|
self._last_mtime: float = -1.0
|
||||||
|
self._last_sha256: Optional[str] = None
|
||||||
|
self._last_status: Optional[str] = None
|
||||||
|
self._last_status_label: Optional[str] = None
|
||||||
|
self._last_version: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> AgyEligibilityService:
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._instance_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
"""Clear cached eligibility state to force re-reading binary on next check."""
|
||||||
|
with self._lock:
|
||||||
|
self._cached_state = None
|
||||||
|
self._last_mtime = -1.0
|
||||||
|
|
||||||
|
def _extract_version(self, exe_path: str) -> str:
|
||||||
|
"""Truthfully extract version from agy executable."""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
[exe_path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=build_safe_subprocess_env(),
|
||||||
|
**hidden_process_kwargs(),
|
||||||
|
)
|
||||||
|
raw = res.stdout.strip() or res.stderr.strip()
|
||||||
|
if raw:
|
||||||
|
# E.g. "1.1.23" or "Antigravity CLI 1.1.23"
|
||||||
|
m = re.search(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?|\d+\.\d+)", raw)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return raw.splitlines()[0][:50]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Could not run agy --version: %s", exc)
|
||||||
|
return "Н/Д (не удалось определить версию)"
|
||||||
|
|
||||||
|
def _analyze_binary_bytes(self, data: bytes) -> Tuple[str, str, str]:
|
||||||
|
"""Analyze binary machine code bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(status, status_label_ru, detail_ru)
|
||||||
|
"""
|
||||||
|
# 1. Check for patched x86-64 signature
|
||||||
|
if X86_PATCH_SHORT.search(data) or X86_PATCH_NEAR.search(data):
|
||||||
|
return (
|
||||||
|
STATUS_CHECK_REMOVED,
|
||||||
|
"Проверка снята",
|
||||||
|
"Патч начальной проверки доступности активен. Ветвление направлено в разрешённый режим.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Check for original x86-64 signature
|
||||||
|
if X86_ORIG_SHORT.search(data) or X86_ORIG_NEAR.search(data):
|
||||||
|
return (
|
||||||
|
STATUS_CHECK_ACTIVE,
|
||||||
|
"Проверка на месте",
|
||||||
|
"Проверка доступности Antigravity активна. Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Check for arm64 patched / orig if context matches
|
||||||
|
if ARM64_PATCH_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
|
||||||
|
if not ARM64_ORIG_PATTERN.search(data):
|
||||||
|
return (
|
||||||
|
STATUS_CHECK_REMOVED,
|
||||||
|
"Проверка снята",
|
||||||
|
"Патч начальной проверки доступности (ARM64) активен.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if ARM64_ORIG_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
|
||||||
|
return (
|
||||||
|
STATUS_CHECK_ACTIVE,
|
||||||
|
"Проверка на месте",
|
||||||
|
"Проверка доступности Antigravity (ARM64) активна. Аккаунт может отклоняться Google по региону.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Unknown / Unsupported
|
||||||
|
return (
|
||||||
|
STATUS_UNKNOWN,
|
||||||
|
"Н/Д: сигнатура проверки не найдена",
|
||||||
|
"Сигнатура проверки доступности не найдена в бинарнике (возможно, неподдерживаемая версия agy).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_eligibility_state(
|
||||||
|
self,
|
||||||
|
force: bool = False,
|
||||||
|
custom_binary_path: Optional[str | Path] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Perform 100% read-only evaluation of agy binary eligibility state.
|
||||||
|
|
||||||
|
Guarantees:
|
||||||
|
- NEVER writes to or alters the executable file.
|
||||||
|
- Calculates SHA-256 before and after evaluation.
|
||||||
|
- Emits EventBus event and EventLogService audit record upon state transitions.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Resolve binary path
|
||||||
|
if custom_binary_path:
|
||||||
|
exe_path_str = str(custom_binary_path)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
exe_path_str = get_agy_exe()
|
||||||
|
except Exception as exc:
|
||||||
|
exe_path_str = ""
|
||||||
|
error_msg = str(exc)
|
||||||
|
|
||||||
|
settings = get_hub_settings()
|
||||||
|
patch_script_path = settings.get("agy_patch_script_path", "").strip()
|
||||||
|
|
||||||
|
if not exe_path_str:
|
||||||
|
state = {
|
||||||
|
"status": STATUS_UNKNOWN,
|
||||||
|
"status_label_ru": "Н/Д: исполняемый файл agy не найден",
|
||||||
|
"detail_ru": f"Утилита agy не обнаружена в системе: {error_msg if 'error_msg' in locals() else 'путь не найден'}",
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": "",
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": 0,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": patch_script_path,
|
||||||
|
}
|
||||||
|
self._cached_state = state
|
||||||
|
return dict(state)
|
||||||
|
|
||||||
|
p = Path(exe_path_str)
|
||||||
|
if not p.is_file():
|
||||||
|
state = {
|
||||||
|
"status": STATUS_UNKNOWN,
|
||||||
|
"status_label_ru": "Н/Д: файл не найден",
|
||||||
|
"detail_ru": f"Файл {p} не существует или не является файлом",
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": str(p),
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": 0,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": patch_script_path,
|
||||||
|
}
|
||||||
|
self._cached_state = state
|
||||||
|
return dict(state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat = p.stat()
|
||||||
|
mtime = stat.st_mtime
|
||||||
|
size = stat.st_size
|
||||||
|
except OSError as exc:
|
||||||
|
state = {
|
||||||
|
"status": STATUS_UNKNOWN,
|
||||||
|
"status_label_ru": f"Н/Д: нет доступа к файлу ({exc.strerror or exc})",
|
||||||
|
"detail_ru": f"Не удалось прочитать атрибуты файла {p}: {exc}",
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": str(p),
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": 0,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": patch_script_path,
|
||||||
|
}
|
||||||
|
self._cached_state = state
|
||||||
|
return dict(state)
|
||||||
|
|
||||||
|
# Return cached state if nothing changed and not forced
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and self._cached_state is not None
|
||||||
|
and self._last_binary_path == str(p)
|
||||||
|
and self._last_mtime == mtime
|
||||||
|
):
|
||||||
|
# Update patch_script_path if settings changed
|
||||||
|
self._cached_state["patch_script_path"] = patch_script_path
|
||||||
|
return dict(self._cached_state)
|
||||||
|
|
||||||
|
# Read-only binary read and hash computation
|
||||||
|
try:
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
except OSError as exc:
|
||||||
|
state = {
|
||||||
|
"status": STATUS_UNKNOWN,
|
||||||
|
"status_label_ru": f"Н/Д: ошибка чтения файла ({exc.strerror or exc})",
|
||||||
|
"detail_ru": f"Не удалось прочитать бинарный файл {p}: {exc}",
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": str(p),
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": size,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": patch_script_path,
|
||||||
|
}
|
||||||
|
self._cached_state = state
|
||||||
|
return dict(state)
|
||||||
|
|
||||||
|
sha256_hash = hashlib.sha256(data).hexdigest()
|
||||||
|
status, status_label_ru, detail_ru = self._analyze_binary_bytes(data)
|
||||||
|
|
||||||
|
# Extract version
|
||||||
|
version = self._extract_version(str(p))
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"status": status,
|
||||||
|
"status_label_ru": status_label_ru,
|
||||||
|
"detail_ru": detail_ru,
|
||||||
|
"version": version,
|
||||||
|
"binary_path": str(p),
|
||||||
|
"binary_sha256": sha256_hash,
|
||||||
|
"binary_size_bytes": len(data),
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": patch_script_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect state transition and emit notifications (P0-3)
|
||||||
|
previous_status = self._last_status
|
||||||
|
previous_label = self._last_status_label or previous_status or "Н/Д"
|
||||||
|
previous_sha = self._last_sha256
|
||||||
|
|
||||||
|
if previous_status is not None and previous_status != status:
|
||||||
|
logger.info(
|
||||||
|
"AGY eligibility state changed: %s -> %s (sha256: %s)",
|
||||||
|
previous_status,
|
||||||
|
status,
|
||||||
|
sha256_hash[:12],
|
||||||
|
)
|
||||||
|
EventBus.get().publish(EVENT_AGY_ELIGIBILITY_CHANGED, dict(state))
|
||||||
|
|
||||||
|
level = "warning" if status == STATUS_CHECK_ACTIVE else "info"
|
||||||
|
EventLogService.get().log(
|
||||||
|
category="security",
|
||||||
|
message=(
|
||||||
|
f"Состояние проверки доступности agy изменилось: "
|
||||||
|
f"{previous_label} → {status_label_ru}"
|
||||||
|
),
|
||||||
|
details=f"Исполняемый файл: {p} (SHA-256: {sha256_hash[:16]}..., Версия: {version})",
|
||||||
|
level=level,
|
||||||
|
actor="system",
|
||||||
|
action="agy_eligibility_change",
|
||||||
|
target_profile="antigravity",
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
|
elif previous_sha is not None and previous_sha != sha256_hash:
|
||||||
|
logger.info("AGY binary hash changed: %s -> %s", previous_sha[:12], sha256_hash[:12])
|
||||||
|
EventLogService.get().log(
|
||||||
|
category="system",
|
||||||
|
message=f"Обнаружено изменение исполняемого файла agy (SHA-256: {sha256_hash[:16]}..., статус: {status_label_ru})",
|
||||||
|
level="info",
|
||||||
|
actor="system",
|
||||||
|
action="agy_binary_updated",
|
||||||
|
target_profile="antigravity",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_binary_path = str(p)
|
||||||
|
self._last_mtime = mtime
|
||||||
|
self._last_sha256 = sha256_hash
|
||||||
|
self._last_status = status
|
||||||
|
self._last_status_label = status_label_ru
|
||||||
|
self._last_version = version
|
||||||
|
self._cached_state = state
|
||||||
|
|
||||||
|
return dict(state)
|
||||||
|
|
@ -9,17 +9,16 @@ 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
|
||||||
|
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
# Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник,
|
||||||
try:
|
# тот же, что и у скриптов проверки: одна реализация, одно поведение.
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
force_utf8_output()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def print_router_status() -> int:
|
def print_router_status() -> int:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,20 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
||||||
raise ValueError("Для этого провайдера используйте вход через авторизацию")
|
raise ValueError("Для этого провайдера используйте вход через авторизацию")
|
||||||
if provider not in ("local", "vllm", "ollama") and not token:
|
if provider not in ("local", "vllm", "ollama") and not token:
|
||||||
raise ValueError("Не указан API-ключ")
|
raise ValueError("Не указан API-ключ")
|
||||||
|
|
||||||
|
# Подписочные аккаунты Codex (ChatGPT) и Claude работают по OAuth, а не
|
||||||
|
# по платформенному ключу. Проверка ниже обращается к каталогу моделей,
|
||||||
|
# где платформенному ключу нужны права api.model.read: подписочный
|
||||||
|
# токен там получает 403 или 401, хотя аккаунт исправен и в Hermes
|
||||||
|
# работает. Предупреждаем об этом заранее, чтобы отказ не выглядел
|
||||||
|
# поломкой аккаунта.
|
||||||
|
subscription_note = ""
|
||||||
|
if provider in ("openai-codex", "codex", "claude", "anthropic"):
|
||||||
|
subscription_note = (
|
||||||
|
" Если это аккаунт по подписке (ChatGPT Plus/Pro или Claude Max), "
|
||||||
|
"ключ здесь не подойдёт: такие аккаунты подключаются входом по ссылке "
|
||||||
|
"на предыдущем шаге мастера, а не API-ключом."
|
||||||
|
)
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if provider == "claude":
|
if provider == "claude":
|
||||||
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
|
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
|
||||||
|
|
@ -63,19 +77,85 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
|
||||||
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
|
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
|
||||||
if preferred_model and preferred_model not in models:
|
if preferred_model and preferred_model not in models:
|
||||||
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
|
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
|
||||||
# NVIDIA also exposes a public catalog. Validate with a real request.
|
# Каталог NVIDIA общий для всех, а доступ к конкретной модели даётся
|
||||||
|
# по аккаунту. Успешный список уже доказывает, что ключ рабочий:
|
||||||
|
# сервер опознал аккаунт и ответил. Пробный запрос — уточнение, а не
|
||||||
|
# условие. Раньше его отказ («Function ... Not found for account»)
|
||||||
|
# объявлялся провалом подключения, хотя ключ был верным.
|
||||||
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
|
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
|
||||||
if not preferred_model and not chat_models:
|
probe_target = preferred_model or (chat_models[0] if chat_models else None)
|
||||||
raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста")
|
if probe_target:
|
||||||
result = request("/chat/completions", {"model": preferred_model or chat_models[0],
|
try:
|
||||||
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
|
result = request("/chat/completions", {"model": probe_target,
|
||||||
if not isinstance(result.get("choices"), list) or not result["choices"]:
|
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
|
||||||
raise ValueError("NVIDIA не вернула результат тестового запроса")
|
if not isinstance(result.get("choices"), list) or not result["choices"]:
|
||||||
|
probe_note = f"модель {probe_target} не вернула результат"
|
||||||
|
else:
|
||||||
|
probe_note = ""
|
||||||
|
except Exception as probe_exc:
|
||||||
|
# 401 и 403 — ключ отвергнут, это отказ подключения.
|
||||||
|
# 404 и прочее — ключ принят, но модель аккаунту не выдана:
|
||||||
|
# NVIDIA отвечает «Function ... Not found for account <id>»,
|
||||||
|
# то есть аккаунт опознан. Валить подключение из-за этого нельзя.
|
||||||
|
code = getattr(probe_exc, "code", None)
|
||||||
|
if code in (401, 403):
|
||||||
|
raise
|
||||||
|
probe_note = f"модель {probe_target} недоступна вашему аккаунту ({str(probe_exc)[:120]})"
|
||||||
|
else:
|
||||||
|
probe_note = "чат-модель для пробы не найдена"
|
||||||
|
if probe_note:
|
||||||
|
return {"ok": True, "data": {"models": models, "base_url": base_url},
|
||||||
|
"message": f"Ключ принят, моделей в каталоге: {len(models)}. Проба: {probe_note}. Выберите доступную модель."}
|
||||||
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
|
||||||
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
exc_str = str(exc)
|
||||||
|
exc_lower = exc_str.lower()
|
||||||
|
# У Ollama Cloud локального сервера нет вовсе: там только ключ. Если
|
||||||
|
# ключ задан, а локальный адрес не отвечает, это не отказ подключения —
|
||||||
|
# аккаунт облачный. Проверяем его по общедоступному каталогу и
|
||||||
|
# принимаем, честно сказав, что локальные модели недоступны.
|
||||||
|
if provider == "ollama" and token and any(
|
||||||
|
k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "refused", "failed to connect", "target machine actively refused")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
cloud = urllib.request.Request(
|
||||||
|
"https://ollama.com/api/tags",
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(cloud, timeout=15) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
cloud_models = sorted({
|
||||||
|
m.get("name") for m in (payload.get("models") or [])
|
||||||
|
if isinstance(m, dict) and isinstance(m.get("name"), str)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": (
|
||||||
|
f"Облачный аккаунт Ollama принят. Моделей в каталоге: {len(cloud_models)}. "
|
||||||
|
f"Локальный сервер по адресу {base_url} не отвечает — локальные модели недоступны."
|
||||||
|
),
|
||||||
|
"data": {"models": cloud_models, "base_url": base_url, "cloud_only": True},
|
||||||
|
}
|
||||||
|
except Exception as cloud_exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": (
|
||||||
|
f"Локальный сервер {base_url} не отвечает, и облачный каталог Ollama недоступен: "
|
||||||
|
f"{str(cloud_exc)[:160]}"
|
||||||
|
),
|
||||||
|
"data": {"models": []},
|
||||||
|
}
|
||||||
|
if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": f"Не удалось подключиться к {base_url} (Connection refused). Если Ollama работает на сервере или другой машине, укажите её сетевой адрес (например, http://192.168.1.81:11434).",
|
||||||
|
"data": {"models": []},
|
||||||
|
}
|
||||||
if isinstance(exc, urllib.error.HTTPError):
|
if isinstance(exc, urllib.error.HTTPError):
|
||||||
reason = exc.reason or 'провайдер отклонил запрос'
|
reason = (exc.reason or 'провайдер отклонил запрос')
|
||||||
|
if exc.code in (401, 403) and subscription_note:
|
||||||
|
reason = str(reason) + subscription_note
|
||||||
try:
|
try:
|
||||||
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
|
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
|
||||||
detail = body.get('error') or body.get('detail')
|
detail = body.get('error') or body.get('detail')
|
||||||
|
|
|
||||||
535
src/antigravity_provider/router/context_compressor.py
Normal file
535
src/antigravity_provider/router/context_compressor.py
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
"""Hermes Hub Context Compression Engine (Сжатие контекста).
|
||||||
|
|
||||||
|
Implements Task A56 requirements:
|
||||||
|
- P0-1: Configurable compressor role/profile (not hardcoded to port 8082), excluded from Hermes default routing chains.
|
||||||
|
- P0-2: Context-fill threshold (measured via /props and /tokenize, default 75%), fresh window preserved verbatim, no recursive double-compression.
|
||||||
|
- P0-3: Strict verbatim preservation of factual entities (file paths, ports, IPs, variable/function names, commit SHAs, version numbers, benchmark metrics) with 100% retention guarantee.
|
||||||
|
- P0-4: Transparent telemetry, history store for original uncompressed text, non-blocking graceful fallback if compressor is offline/unconfigured.
|
||||||
|
- P0-5: Shared memory persistence in /srv/projects/AI-Memory indexed by GGUF build metadata.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.compression")
|
||||||
|
|
||||||
|
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
|
||||||
|
COMPRESSION_MEMORY_FILE = SHARED_MEMORY_VAULT / "01_PROJECTS" / "hermes-hub" / "compression_memory.json"
|
||||||
|
|
||||||
|
COMPRESSED_BLOCK_START = "<!-- HERMES_CONTEXT_COMPRESSION_START -->"
|
||||||
|
COMPRESSED_BLOCK_END = "<!-- HERMES_CONTEXT_COMPRESSION_END -->"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FactualEntities:
|
||||||
|
file_paths: List[str] = field(default_factory=list)
|
||||||
|
ip_addresses: List[str] = field(default_factory=list)
|
||||||
|
port_numbers: List[str] = field(default_factory=list)
|
||||||
|
commit_shas: List[str] = field(default_factory=list)
|
||||||
|
version_numbers: List[str] = field(default_factory=list)
|
||||||
|
identifiers: List[str] = field(default_factory=list)
|
||||||
|
metrics: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_count(self) -> int:
|
||||||
|
return (
|
||||||
|
len(self.file_paths)
|
||||||
|
+ len(self.ip_addresses)
|
||||||
|
+ len(self.port_numbers)
|
||||||
|
+ len(self.commit_shas)
|
||||||
|
+ len(self.version_numbers)
|
||||||
|
+ len(self.identifiers)
|
||||||
|
+ len(self.metrics)
|
||||||
|
)
|
||||||
|
|
||||||
|
def all_unique_facts(self) -> Set[str]:
|
||||||
|
items: Set[str] = set()
|
||||||
|
items.update(self.file_paths)
|
||||||
|
items.update(self.ip_addresses)
|
||||||
|
items.update(self.port_numbers)
|
||||||
|
items.update(self.commit_shas)
|
||||||
|
items.update(self.version_numbers)
|
||||||
|
items.update(self.identifiers)
|
||||||
|
items.update(self.metrics)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompressionOutcome:
|
||||||
|
status: str # "SUCCESS", "SKIPPED", "UNCONFIGURED", "ERROR"
|
||||||
|
status_message: str
|
||||||
|
tokens_before: int = 0
|
||||||
|
tokens_after: int = 0
|
||||||
|
compression_ratio: float = 1.0
|
||||||
|
saved_tokens: int = 0
|
||||||
|
duration_sec: float = 0.0
|
||||||
|
facts_total: int = 0
|
||||||
|
facts_retained: int = 0
|
||||||
|
retention_percent: float = 100.0
|
||||||
|
# Итог считается посимвольно, если токенизатор сервера недоступен.
|
||||||
|
tokens_after_is_estimate: bool = True
|
||||||
|
# Полнота, которую дала сама модель, до дописывания недостающих фактов.
|
||||||
|
model_retention_percent: float = 100.0
|
||||||
|
facts_added_by_safeguard: List[str] = field(default_factory=list)
|
||||||
|
retained_facts: List[str] = field(default_factory=list)
|
||||||
|
missing_facts: List[str] = field(default_factory=list)
|
||||||
|
model_name: str = ""
|
||||||
|
gguf_name: str = ""
|
||||||
|
endpoint: str = ""
|
||||||
|
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
original_messages_snapshot: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_factual_entities(text: str) -> FactualEntities:
|
||||||
|
"""Extract factual technical entities from text using strict regex patterns."""
|
||||||
|
if not text:
|
||||||
|
return FactualEntities()
|
||||||
|
|
||||||
|
# 1. Absolute and relative file paths (e.g. /srv/projects/Agent projects/..., ./src/..., benchmarks/foo.md)
|
||||||
|
path_pattern = re.compile(
|
||||||
|
r"(?:/[a-zA-Z0-9._ -]+)+/[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+|(?:/[a-zA-Z0-9._-]+)+[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+|(?:[a-zA-Z0-9._-]+/)+[a-zA-Z0-9._-]+\.[a-zA-Z0-9]+"
|
||||||
|
)
|
||||||
|
raw_paths = path_pattern.findall(text)
|
||||||
|
file_paths = sorted(set(p.strip() for p in raw_paths if p.strip()))
|
||||||
|
|
||||||
|
# 2. IP addresses (e.g. 192.168.1.81, 127.0.0.1)
|
||||||
|
ip_pattern = re.compile(r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b")
|
||||||
|
ip_addresses = sorted(set(ip_pattern.findall(text)))
|
||||||
|
|
||||||
|
# 3. Ports (e.g. :8081, :8082, :8765, port 8081)
|
||||||
|
port_pattern = re.compile(r"(?::|\bport\s+)([0-9]{4,5})\b", re.IGNORECASE)
|
||||||
|
port_matches = port_pattern.findall(text)
|
||||||
|
port_numbers = sorted(set(f":{p}" if not p.startswith(":") else p for p in port_matches))
|
||||||
|
|
||||||
|
# 4. Commit SHAs and hex tokens (7-64 hex chars)
|
||||||
|
sha_pattern = re.compile(r"\b[0-9a-fA-F]{7,64}\b")
|
||||||
|
raw_shas = sha_pattern.findall(text)
|
||||||
|
# Filter out pure decimal numbers or common words
|
||||||
|
commit_shas = sorted(set(
|
||||||
|
s for s in raw_shas
|
||||||
|
if any(c in "abcdefABCDEF" for c in s) and 7 <= len(s) <= 64
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5. Version numbers (e.g. v0.1.2, 0.1.1, v0.1.2-b2)
|
||||||
|
ver_pattern = re.compile(r"\bv?[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[a-zA-Z0-9.]+)?\b")
|
||||||
|
version_numbers = sorted(set(v for v in ver_pattern.findall(text) if v not in ip_addresses))
|
||||||
|
|
||||||
|
# 6. Specific benchmark and hardware numbers (e.g. 107.4 tok/s, 30008 MiB, 229376 tokens, 224K, 64K)
|
||||||
|
metric_pattern = re.compile(r"\b[0-9]+(?:\.[0-9]+)?\s*(?:tok/s|t/s|MiB|GiB|MB|GB|tokens|tok|K|k)\b")
|
||||||
|
metrics = sorted(set(metric_pattern.findall(text)))
|
||||||
|
|
||||||
|
# 7. Key Python / System Identifiers
|
||||||
|
id_pattern = re.compile(r"\b(?:LocalSupervisor|DualCoderPipeline|ContextCompressor|Qwen3-Coder|Qwen3-4B|Phi-4|Granite|llama-server|systemd)\b")
|
||||||
|
identifiers = sorted(set(id_pattern.findall(text)))
|
||||||
|
|
||||||
|
return FactualEntities(
|
||||||
|
file_paths=file_paths,
|
||||||
|
ip_addresses=ip_addresses,
|
||||||
|
port_numbers=port_numbers,
|
||||||
|
commit_shas=commit_shas,
|
||||||
|
version_numbers=version_numbers,
|
||||||
|
identifiers=identifiers,
|
||||||
|
metrics=metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_facts_retention(summary: str, expected_facts: FactualEntities) -> Tuple[float, List[str], List[str]]:
|
||||||
|
"""Check how many expected facts are present verbatim in the summary."""
|
||||||
|
all_facts = expected_facts.all_unique_facts()
|
||||||
|
if not all_facts:
|
||||||
|
return 100.0, [], []
|
||||||
|
|
||||||
|
preserved: List[str] = []
|
||||||
|
missing: List[str] = []
|
||||||
|
|
||||||
|
for fact in all_facts:
|
||||||
|
# Check direct substring match
|
||||||
|
if fact in summary or (fact.startswith(":") and fact[1:] in summary):
|
||||||
|
preserved.append(fact)
|
||||||
|
else:
|
||||||
|
missing.append(fact)
|
||||||
|
|
||||||
|
retention_percent = (len(preserved) / len(all_facts)) * 100.0
|
||||||
|
return retention_percent, preserved, missing
|
||||||
|
|
||||||
|
|
||||||
|
class ContextCompressor:
|
||||||
|
"""Orchestrates high-fidelity LLM context compression."""
|
||||||
|
|
||||||
|
DEFAULT_THRESHOLD_PERCENT: float = 75.0
|
||||||
|
DEFAULT_KEEP_RECENT: int = 3
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
memory_file: Optional[Path] = None,
|
||||||
|
):
|
||||||
|
self.memory_file = memory_file or COMPRESSION_MEMORY_FILE
|
||||||
|
self._history_snapshots: List[CompressionOutcome] = []
|
||||||
|
|
||||||
|
def get_compression_history(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""Return in-memory history of recent compressions."""
|
||||||
|
return [asdict(o) for o in reversed(self._history_snapshots[-limit:])]
|
||||||
|
|
||||||
|
def resolve_compressor_endpoint(
|
||||||
|
self,
|
||||||
|
profile_config: Optional[Any] = None,
|
||||||
|
custom_base_url: Optional[str] = None,
|
||||||
|
) -> Tuple[str, str, str]:
|
||||||
|
"""Resolve base_url, model_name, and auth token for compressor."""
|
||||||
|
if custom_base_url:
|
||||||
|
return custom_base_url.rstrip("/"), "default", ""
|
||||||
|
|
||||||
|
if profile_config is not None:
|
||||||
|
base_url = (
|
||||||
|
getattr(profile_config, "custom_base_url", None)
|
||||||
|
or (profile_config.auth_config.get("base_url") if hasattr(profile_config, "auth_config") and isinstance(profile_config.auth_config, dict) else None)
|
||||||
|
or os.environ.get("LOCAL_COMPRESSOR_BASE_URL")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
model_name = profile_config.preferred_models[0] if getattr(profile_config, "preferred_models", None) else "default"
|
||||||
|
token = profile_config.auth_config.get("api_key") or profile_config.auth_config.get("token") if hasattr(profile_config, "auth_config") and isinstance(profile_config.auth_config, dict) else ""
|
||||||
|
return str(base_url).rstrip("/"), str(model_name), str(token or "")
|
||||||
|
|
||||||
|
# Порт 8082 сегодняшний, завтра другой: зашивать его нельзя. Нет
|
||||||
|
# адреса — значит компрессор не настроен, и это отдельное состояние,
|
||||||
|
# а не повод молча постучаться в 8082.
|
||||||
|
env_url = os.environ.get("LOCAL_COMPRESSOR_BASE_URL", "")
|
||||||
|
return env_url.rstrip("/"), "default", ""
|
||||||
|
|
||||||
|
def compress_messages_if_needed(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
target_context_limit: int,
|
||||||
|
current_token_count: int,
|
||||||
|
compressor_profile: Optional[Any] = None,
|
||||||
|
threshold_percent: float = DEFAULT_THRESHOLD_PERCENT,
|
||||||
|
keep_recent_messages: int = DEFAULT_KEEP_RECENT,
|
||||||
|
timeout_sec: float = 60.0,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
|
||||||
|
"""Compress conversation history if current_token_count exceeds threshold.
|
||||||
|
|
||||||
|
Preserves system prompt and last `keep_recent_messages` untouched.
|
||||||
|
Never nests compressed summaries recursively.
|
||||||
|
Guarantees 100% factual retention.
|
||||||
|
"""
|
||||||
|
# P0-1: If compressor profile is not configured
|
||||||
|
if not self.resolve_compressor_endpoint(compressor_profile)[0]:
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="UNCONFIGURED",
|
||||||
|
status_message=(
|
||||||
|
"Н/Д: модель для сжатия не выбрана"
|
||||||
|
if not compressor_profile
|
||||||
|
else "Н/Д: у выбранного профиля не задан адрес сервера"
|
||||||
|
),
|
||||||
|
tokens_before=current_token_count,
|
||||||
|
tokens_after=current_token_count,
|
||||||
|
compression_ratio=1.0,
|
||||||
|
)
|
||||||
|
return messages, outcome
|
||||||
|
|
||||||
|
# P0-2: When to compress — check threshold
|
||||||
|
threshold_tokens = int(target_context_limit * (threshold_percent / 100.0))
|
||||||
|
if current_token_count < threshold_tokens:
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="SKIPPED",
|
||||||
|
status_message=f"В пределах нормы ({current_token_count}/{threshold_tokens} токенов, {threshold_percent}%)",
|
||||||
|
tokens_before=current_token_count,
|
||||||
|
tokens_after=current_token_count,
|
||||||
|
compression_ratio=1.0,
|
||||||
|
)
|
||||||
|
return messages, outcome
|
||||||
|
|
||||||
|
# Not enough messages to compress (e.g. only system + 1 user prompt)
|
||||||
|
system_msg: List[Dict[str, Any]] = []
|
||||||
|
conversation: List[Dict[str, Any]] = list(messages)
|
||||||
|
if conversation and conversation[0].get("role") == "system":
|
||||||
|
system_msg = [conversation[0]]
|
||||||
|
conversation = conversation[1:]
|
||||||
|
|
||||||
|
if len(conversation) <= keep_recent_messages:
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="SKIPPED",
|
||||||
|
status_message=f"Слишком мало сообщений для разделения ({len(conversation)} <= {keep_recent_messages})",
|
||||||
|
tokens_before=current_token_count,
|
||||||
|
tokens_after=current_token_count,
|
||||||
|
compression_ratio=1.0,
|
||||||
|
)
|
||||||
|
return messages, outcome
|
||||||
|
|
||||||
|
# Partition into old history to compress and fresh window to keep verbatim
|
||||||
|
old_history = conversation[:-keep_recent_messages]
|
||||||
|
fresh_window = conversation[-keep_recent_messages:]
|
||||||
|
|
||||||
|
# P0-2 & P0-3: Extract existing summary block if present to prevent recursive double compression
|
||||||
|
extracted_facts: List[FactualEntities] = []
|
||||||
|
text_segments_to_compress: List[str] = []
|
||||||
|
|
||||||
|
for msg in old_history:
|
||||||
|
content = str(msg.get("content", ""))
|
||||||
|
# Extract facts before stripping tags
|
||||||
|
extracted_facts.append(extract_factual_entities(content))
|
||||||
|
|
||||||
|
if COMPRESSED_BLOCK_START in content and COMPRESSED_BLOCK_END in content:
|
||||||
|
# Extract the inner text of previously compressed context
|
||||||
|
match = re.search(
|
||||||
|
rf"{re.escape(COMPRESSED_BLOCK_START)}\s*(.*?)\s*{re.escape(COMPRESSED_BLOCK_END)}",
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
prev_summary = match.group(1).strip()
|
||||||
|
text_segments_to_compress.append(f"[РАНЕЕ СОХРАНЁННЫЙ КОНТЕКСТ]:\n{prev_summary}")
|
||||||
|
else:
|
||||||
|
text_segments_to_compress.append(content)
|
||||||
|
else:
|
||||||
|
role_label = msg.get("role", "user").upper()
|
||||||
|
text_segments_to_compress.append(f"[{role_label}]:\n{content}")
|
||||||
|
|
||||||
|
combined_history_text = "\n\n".join(text_segments_to_compress)
|
||||||
|
|
||||||
|
# Merge all expected facts
|
||||||
|
all_expected_facts = FactualEntities()
|
||||||
|
for ef in extracted_facts:
|
||||||
|
all_expected_facts.file_paths.extend(ef.file_paths)
|
||||||
|
all_expected_facts.ip_addresses.extend(ef.ip_addresses)
|
||||||
|
all_expected_facts.port_numbers.extend(ef.port_numbers)
|
||||||
|
all_expected_facts.commit_shas.extend(ef.commit_shas)
|
||||||
|
all_expected_facts.version_numbers.extend(ef.version_numbers)
|
||||||
|
all_expected_facts.identifiers.extend(ef.identifiers)
|
||||||
|
all_expected_facts.metrics.extend(ef.metrics)
|
||||||
|
|
||||||
|
all_expected_facts.file_paths = sorted(set(all_expected_facts.file_paths))
|
||||||
|
all_expected_facts.ip_addresses = sorted(set(all_expected_facts.ip_addresses))
|
||||||
|
all_expected_facts.port_numbers = sorted(set(all_expected_facts.port_numbers))
|
||||||
|
all_expected_facts.commit_shas = sorted(set(all_expected_facts.commit_shas))
|
||||||
|
all_expected_facts.version_numbers = sorted(set(all_expected_facts.version_numbers))
|
||||||
|
all_expected_facts.identifiers = sorted(set(all_expected_facts.identifiers))
|
||||||
|
all_expected_facts.metrics = sorted(set(all_expected_facts.metrics))
|
||||||
|
|
||||||
|
# P0-4: Execute compression against the resolved compressor model
|
||||||
|
base_url, model_name, token = self.resolve_compressor_endpoint(compressor_profile)
|
||||||
|
api_url = f"{base_url}/chat/completions" if not base_url.endswith("/chat/completions") else base_url
|
||||||
|
|
||||||
|
system_instruction = (
|
||||||
|
"You are a precise technical context compression engine.\n"
|
||||||
|
"Summarize the technical history concisely into clear structured bullet points.\n"
|
||||||
|
"CRITICAL REQUIREMENT: Strictly preserve ALL factual entities VERBATIM:\n"
|
||||||
|
"- Exact file paths and directory names (e.g. /path/to/file.py)\n"
|
||||||
|
"- IP addresses and hostnames (e.g. 192.168.1.81)\n"
|
||||||
|
"- Port numbers (e.g. :8081, :8082, :8765)\n"
|
||||||
|
"- Commit SHAs and cryptographic hashes (e.g. 26f7d2c, 8e75dc6)\n"
|
||||||
|
"- Version numbers (e.g. v0.1.2)\n"
|
||||||
|
"- Exact benchmark metrics and quantities (e.g. 107.4 tok/s, 30008 MiB, 224K)\n"
|
||||||
|
"Never generalize, invent, or omit technical identifiers."
|
||||||
|
)
|
||||||
|
|
||||||
|
compression_request_payload = {
|
||||||
|
"model": model_name or "default",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_instruction},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Please summarize the following execution history while retaining 100% of technical facts:\n\n{combined_history_text}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
req_headers = {"Content-Type": "application/json", "User-Agent": "Hermes-ContextCompressor/1.0"}
|
||||||
|
if token:
|
||||||
|
req_headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
req_data = json.dumps(compression_request_payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(api_url, data=req_data, headers=req_headers, method="POST")
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||||||
|
resp_data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
choices = resp_data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
raise RuntimeError("Compressor returned empty choices")
|
||||||
|
|
||||||
|
raw_summary = choices[0].get("message", {}).get("content", "").strip()
|
||||||
|
if not raw_summary:
|
||||||
|
raise RuntimeError("Compressor returned empty content")
|
||||||
|
|
||||||
|
# Extract model GGUF build metadata if available
|
||||||
|
gguf_model = str(resp_data.get("model", model_name or "compressor"))
|
||||||
|
|
||||||
|
# P0-3: Verify facts retention
|
||||||
|
retention_rate, preserved, missing = verify_facts_retention(raw_summary, all_expected_facts)
|
||||||
|
|
||||||
|
# Полнота, которую дала САМА модель. Её нельзя терять: ниже
|
||||||
|
# недостающие факты дописываются списком, и после этого проверка
|
||||||
|
# покажет сто процентов. Замер на сервере владельца дал 97,4% —
|
||||||
|
# один факт из тридцати восьми модель потеряла. Показывая только
|
||||||
|
# исправленное число, мы скрыли бы от владельца, что модель
|
||||||
|
# теряет факты, и он не заметил бы, когда станет хуже.
|
||||||
|
model_retention_rate = retention_rate
|
||||||
|
model_missing = list(missing)
|
||||||
|
|
||||||
|
# P0-3 Safeguard: If any critical technical entities were omitted by model, append explicit factual ledger
|
||||||
|
if missing:
|
||||||
|
logger.info("Context compressor missed %d facts. Appending verbatim factual safeguard ledger.", len(missing))
|
||||||
|
facts_ledger = "\n### Ключевые сохранённые факты:\n" + "\n".join(f"- `{f}`" for f in missing)
|
||||||
|
final_summary = f"{raw_summary}\n{facts_ledger}"
|
||||||
|
retention_rate, preserved, missing = verify_facts_retention(final_summary, all_expected_facts)
|
||||||
|
else:
|
||||||
|
final_summary = raw_summary
|
||||||
|
|
||||||
|
# Format the compressed block with clear delimiter
|
||||||
|
formatted_compressed_content = (
|
||||||
|
f"{COMPRESSED_BLOCK_START}\n"
|
||||||
|
f"## Сжатая сводка предшествующего контекста\n"
|
||||||
|
f"{final_summary}\n"
|
||||||
|
f"{COMPRESSED_BLOCK_END}"
|
||||||
|
)
|
||||||
|
|
||||||
|
compressed_message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": formatted_compressed_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_messages = system_msg + [compressed_message] + fresh_window
|
||||||
|
|
||||||
|
# Approximate/count new tokens
|
||||||
|
est_before = current_token_count
|
||||||
|
est_summary_tokens = max(1, int(len(formatted_compressed_content) / 3.5))
|
||||||
|
est_fresh_tokens = sum(max(1, int(len(str(m.get("content", ""))) / 3.5)) for m in fresh_window)
|
||||||
|
est_system_tokens = sum(max(1, int(len(str(m.get("content", ""))) / 3.5)) for m in system_msg)
|
||||||
|
est_after = est_system_tokens + est_summary_tokens + est_fresh_tokens
|
||||||
|
|
||||||
|
ratio = round(est_after / max(1, est_before), 2)
|
||||||
|
saved = max(0, est_before - est_after)
|
||||||
|
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="SUCCESS",
|
||||||
|
status_message=(
|
||||||
|
f"Контекст сжат: {est_before} → {est_after} токенов "
|
||||||
|
f"({ratio}x, экономия {saved}) за {elapsed:.2f}с. "
|
||||||
|
f"Фактов сохранено: {len(preserved)}/{all_expected_facts.total_count} "
|
||||||
|
f"({retention_rate:.1f}%)"
|
||||||
|
+ (
|
||||||
|
f"; {len(model_missing)} из них дописано списком, "
|
||||||
|
f"сама модель сохранила {model_retention_rate:.1f}%."
|
||||||
|
if model_missing
|
||||||
|
else ", все — самой моделью."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tokens_before=est_before,
|
||||||
|
tokens_after=est_after,
|
||||||
|
compression_ratio=ratio,
|
||||||
|
saved_tokens=saved,
|
||||||
|
duration_sec=round(elapsed, 2),
|
||||||
|
facts_total=all_expected_facts.total_count,
|
||||||
|
facts_retained=len(preserved),
|
||||||
|
retention_percent=retention_rate,
|
||||||
|
model_retention_percent=model_retention_rate,
|
||||||
|
facts_added_by_safeguard=model_missing,
|
||||||
|
retained_facts=preserved,
|
||||||
|
missing_facts=missing,
|
||||||
|
model_name=model_name,
|
||||||
|
gguf_name=gguf_model,
|
||||||
|
endpoint=base_url,
|
||||||
|
original_messages_snapshot=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in in-memory snapshot history
|
||||||
|
self._history_snapshots.append(outcome)
|
||||||
|
|
||||||
|
# P0-5: Record to AI-Memory
|
||||||
|
self._record_to_shared_memory(outcome)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Context compression successful: %d -> %d tokens (%.2fx) in %.2fs. Model: %s",
|
||||||
|
est_before,
|
||||||
|
est_after,
|
||||||
|
ratio,
|
||||||
|
elapsed,
|
||||||
|
gguf_model,
|
||||||
|
)
|
||||||
|
return new_messages, outcome
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
logger.warning("Context compression failed (%s). Continuing with uncompressed context: %s", base_url, err)
|
||||||
|
|
||||||
|
# P0-4: Failure does NOT crash task. Proceed with original uncompressed messages
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="ERROR",
|
||||||
|
status_message=f"Ошибка сжатия ({err}). Задача продолжается на исходном контексте.",
|
||||||
|
tokens_before=current_token_count,
|
||||||
|
tokens_after=current_token_count,
|
||||||
|
compression_ratio=1.0,
|
||||||
|
duration_sec=round(elapsed, 2),
|
||||||
|
model_name=model_name,
|
||||||
|
endpoint=base_url,
|
||||||
|
original_messages_snapshot=messages,
|
||||||
|
)
|
||||||
|
self._history_snapshots.append(outcome)
|
||||||
|
return messages, outcome
|
||||||
|
|
||||||
|
def _record_to_shared_memory(self, outcome: CompressionOutcome) -> None:
|
||||||
|
"""Persist compression history in /srv/projects/AI-Memory indexed by GGUF model."""
|
||||||
|
try:
|
||||||
|
self.memory_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing_data: Dict[str, Any] = {}
|
||||||
|
if self.memory_file.exists():
|
||||||
|
try:
|
||||||
|
existing_data = json.loads(self.memory_file.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
existing_data = {}
|
||||||
|
|
||||||
|
models_map = existing_data.setdefault("compressor_models", {})
|
||||||
|
model_key = Path(outcome.gguf_name).name if outcome.gguf_name else "default_compressor"
|
||||||
|
|
||||||
|
rec = models_map.setdefault(model_key, {
|
||||||
|
"gguf_name": outcome.gguf_name,
|
||||||
|
"total_compressions": 0,
|
||||||
|
"successful_compressions": 0,
|
||||||
|
"avg_compression_ratio": 1.0,
|
||||||
|
"avg_duration_sec": 0.0,
|
||||||
|
"avg_fact_retention_percent": 100.0,
|
||||||
|
"last_used": "",
|
||||||
|
"history": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
rec["total_compressions"] += 1
|
||||||
|
if outcome.status == "SUCCESS":
|
||||||
|
rec["successful_compressions"] += 1
|
||||||
|
n = rec["successful_compressions"]
|
||||||
|
rec["avg_compression_ratio"] = round(((rec["avg_compression_ratio"] * (n - 1)) + outcome.compression_ratio) / n, 3)
|
||||||
|
rec["avg_duration_sec"] = round(((rec["avg_duration_sec"] * (n - 1)) + outcome.duration_sec) / n, 2)
|
||||||
|
rec["avg_fact_retention_percent"] = round(((rec["avg_fact_retention_percent"] * (n - 1)) + outcome.retention_percent) / n, 1)
|
||||||
|
|
||||||
|
rec["last_used"] = outcome.timestamp
|
||||||
|
rec["history"].append({
|
||||||
|
"timestamp": outcome.timestamp,
|
||||||
|
"tokens_before": outcome.tokens_before,
|
||||||
|
"tokens_after": outcome.tokens_after,
|
||||||
|
"ratio": outcome.compression_ratio,
|
||||||
|
"duration_sec": outcome.duration_sec,
|
||||||
|
"retention_percent": outcome.retention_percent,
|
||||||
|
"facts_total": outcome.facts_total,
|
||||||
|
"status": outcome.status,
|
||||||
|
})
|
||||||
|
# Keep history limited to last 50 entries
|
||||||
|
rec["history"] = rec["history"][-50:]
|
||||||
|
|
||||||
|
self.memory_file.write_text(json.dumps(existing_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to record compression memory to %s: %s", self.memory_file, exc)
|
||||||
|
|
@ -24,6 +24,8 @@ EVENT_REFRESH_STARTED = "REFRESH_STARTED"
|
||||||
EVENT_REFRESH_COMPLETED = "REFRESH_COMPLETED"
|
EVENT_REFRESH_COMPLETED = "REFRESH_COMPLETED"
|
||||||
EVENT_REFRESH_FAILED = "REFRESH_FAILED"
|
EVENT_REFRESH_FAILED = "REFRESH_FAILED"
|
||||||
|
|
||||||
|
EVENT_AGY_ELIGIBILITY_CHANGED = "AGY_ELIGIBILITY_CHANGED"
|
||||||
|
|
||||||
|
|
||||||
class EventBus:
|
class EventBus:
|
||||||
"""Central thread-safe EventBus for decoupling backend state changes from UI rendering."""
|
"""Central thread-safe EventBus for decoupling backend state changes from UI rendering."""
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,15 @@ from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .context_compressor import (
|
||||||
|
COMPRESSION_MEMORY_FILE,
|
||||||
|
CompressionOutcome,
|
||||||
|
ContextCompressor,
|
||||||
|
FactualEntities,
|
||||||
|
extract_factual_entities,
|
||||||
|
verify_facts_retention,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
|
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
|
||||||
|
|
@ -74,6 +83,10 @@ class ModelMemoryRecord:
|
||||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Разделитель между сообщениями при подсчёте объёма контекста.
|
||||||
|
MESSAGE_SEPARATOR = "\n\n"
|
||||||
|
|
||||||
|
|
||||||
class LocalSupervisor:
|
class LocalSupervisor:
|
||||||
"""Oversees and regulates work dispatch to local models."""
|
"""Oversees and regulates work dispatch to local models."""
|
||||||
|
|
||||||
|
|
@ -85,16 +98,24 @@ class LocalSupervisor:
|
||||||
self,
|
self,
|
||||||
base_url: str = "http://127.0.0.1:8081",
|
base_url: str = "http://127.0.0.1:8081",
|
||||||
memory_path: Optional[Path] = None,
|
memory_path: Optional[Path] = None,
|
||||||
|
compressor: Optional[ContextCompressor] = None,
|
||||||
):
|
):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
# /props и /tokenize у llama.cpp живут в КОРНЕ, а не под /v1. Проверено
|
||||||
|
# на сервере владельца: /props → 200, /v1/props → 404; то же с
|
||||||
|
# /tokenize. Адаптер передаёт сюда адрес вида .../v1, поэтому без
|
||||||
|
# нормализации оба запроса получали 404, счёт токенов молча падал на
|
||||||
|
# посимвольную оценку, и порог сжатия считался от выдуманного числа.
|
||||||
|
self.root_url = self.base_url[:-3].rstrip("/") if self.base_url.endswith("/v1") else self.base_url
|
||||||
self.memory_path = memory_path or LOCAL_MEMORY_FILE
|
self.memory_path = memory_path or LOCAL_MEMORY_FILE
|
||||||
|
self.compressor = compressor or ContextCompressor()
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
# P0-5: Measured limits via /props and /tokenize
|
# P0-5: Measured limits via /props and /tokenize
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
def query_server_props(self, timeout_sec: float = 3.0) -> ServerPropsResult:
|
def query_server_props(self, timeout_sec: float = 3.0) -> ServerPropsResult:
|
||||||
"""Query real model properties and context limits from live server."""
|
"""Query real model properties and context limits from live server."""
|
||||||
props_url = f"{self.base_url}/props"
|
props_url = f"{self.root_url}/props"
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-LocalSupervisor/1.0"})
|
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-LocalSupervisor/1.0"})
|
||||||
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||||||
|
|
@ -128,7 +149,7 @@ class LocalSupervisor:
|
||||||
if not text:
|
if not text:
|
||||||
return TokenCountResult(tokens_count=0, is_estimated=False, method="exact_empty")
|
return TokenCountResult(tokens_count=0, is_estimated=False, method="exact_empty")
|
||||||
|
|
||||||
tok_url = f"{self.base_url}/tokenize"
|
tok_url = f"{self.root_url}/tokenize"
|
||||||
try:
|
try:
|
||||||
payload = json.dumps({"content": text}).encode("utf-8")
|
payload = json.dumps({"content": text}).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
|
|
@ -395,3 +416,99 @@ class LocalSupervisor:
|
||||||
clean = Path(name).stem
|
clean = Path(name).stem
|
||||||
clean = clean.replace(".gguf", "").replace("-Q4_K_M", "").replace("-Instruct", "").strip()
|
clean = clean.replace(".gguf", "").replace("-Q4_K_M", "").replace("-Instruct", "").strip()
|
||||||
return clean or "local-model"
|
return clean or "local-model"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# P0-1, P0-2, P0-3, P0-4, P0-5: Context Compression Integration
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
def compress_context_if_needed(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
target_context_limit: int,
|
||||||
|
compressor_profile: Optional[Any] = None,
|
||||||
|
threshold_percent: float = 75.0,
|
||||||
|
keep_recent_messages: int = 3,
|
||||||
|
timeout_sec: float = 60.0,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
|
||||||
|
"""Compress old context via ContextCompressor if measured tokens exceed threshold."""
|
||||||
|
full_text = "\n\n".join(str(m.get("content", "")) for m in messages if isinstance(m, dict))
|
||||||
|
current_token_count = self.count_tokens(full_text).tokens_count
|
||||||
|
|
||||||
|
new_messages, outcome = self.compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=target_context_limit,
|
||||||
|
current_token_count=current_token_count,
|
||||||
|
compressor_profile=compressor_profile,
|
||||||
|
threshold_percent=threshold_percent,
|
||||||
|
keep_recent_messages=keep_recent_messages,
|
||||||
|
timeout_sec=timeout_sec,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сжиматель считает итог посимвольно (длина / 3.5): доступа к серверу у
|
||||||
|
# него нет. Здесь он есть, и степень сжатия — величина, которую владелец
|
||||||
|
# читает как измеренную. Пересчитываем настоящим токенизатором, а если
|
||||||
|
# он недоступен, честно помечаем оценкой.
|
||||||
|
if outcome.status == "SUCCESS":
|
||||||
|
new_text = MESSAGE_SEPARATOR.join(
|
||||||
|
str(m.get("content", "")) for m in new_messages if isinstance(m, dict)
|
||||||
|
)
|
||||||
|
counted = self.count_tokens(new_text)
|
||||||
|
outcome.tokens_after = counted.tokens_count
|
||||||
|
outcome.tokens_after_is_estimate = counted.is_estimated
|
||||||
|
outcome.saved_tokens = max(0, outcome.tokens_before - counted.tokens_count)
|
||||||
|
outcome.compression_ratio = round(
|
||||||
|
counted.tokens_count / max(1, outcome.tokens_before), 2
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_messages, outcome
|
||||||
|
|
||||||
|
def get_compression_status(self, compressor_profile: Optional[Any] = None) -> Dict[str, Any]:
|
||||||
|
"""Return real-time diagnostic status of context compressor."""
|
||||||
|
if not compressor_profile and not os.environ.get("LOCAL_COMPRESSOR_BASE_URL"):
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"status": "unconfigured",
|
||||||
|
"display_status": "Н/Д: модель для сжатия не выбрана",
|
||||||
|
"profile_id": None,
|
||||||
|
"endpoint": None,
|
||||||
|
"model": None,
|
||||||
|
"history_count": len(self.compressor._history_snapshots),
|
||||||
|
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url, model_name, _ = self.compressor.resolve_compressor_endpoint(compressor_profile)
|
||||||
|
# Check health of compressor endpoint
|
||||||
|
is_healthy = False
|
||||||
|
n_ctx = 32768
|
||||||
|
props_url = f"{base_url}/props"
|
||||||
|
if props_url.endswith("/v1/props"):
|
||||||
|
props_url = props_url.replace("/v1/props", "/props")
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-CompressorCheck/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
gen_settings = data.get("default_generation_settings", {})
|
||||||
|
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 32768)
|
||||||
|
is_healthy = True
|
||||||
|
except Exception:
|
||||||
|
is_healthy = False
|
||||||
|
|
||||||
|
recent = self.compressor.get_compression_history(limit=5)
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"status": "ready" if is_healthy else "offline",
|
||||||
|
"display_status": (
|
||||||
|
f"🟢 Готов: {model_name} ({base_url}) | Контекст: {n_ctx}"
|
||||||
|
if is_healthy
|
||||||
|
else f"⚠️ Недоступен: {base_url} (сжатие пропускается, задачи не прерываются)"
|
||||||
|
),
|
||||||
|
"profile_id": getattr(compressor_profile, "profile_id", None) or "local-compressor",
|
||||||
|
"endpoint": base_url,
|
||||||
|
"model": model_name,
|
||||||
|
"n_ctx": n_ctx,
|
||||||
|
"is_healthy": is_healthy,
|
||||||
|
"history_count": len(self.compressor._history_snapshots),
|
||||||
|
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
|
||||||
|
"recent_compressions": recent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -369,9 +369,56 @@ class ModelDiscoveryService:
|
||||||
|
|
||||||
if prov in ("antigravity", "google-antigravity"):
|
if prov in ("antigravity", "google-antigravity"):
|
||||||
from antigravity_provider.agy_subprocess import discover_models
|
from antigravity_provider.agy_subprocess import discover_models
|
||||||
main_p = getattr(self._probe_context, "profile_id", None) or ProfileAuthManager.get_main_profile("antigravity") or "ag-orch-fallback"
|
from antigravity_provider.paths import get_hermes_home
|
||||||
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
|
|
||||||
|
candidate_pids: List[str] = []
|
||||||
|
req_p = getattr(self._probe_context, "profile_id", None)
|
||||||
|
if req_p:
|
||||||
|
candidate_pids.append(req_p)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = discover_models(profile_id=main_p)
|
cfg = load_router_config()
|
||||||
|
for pid, pcfg in cfg.profiles.items():
|
||||||
|
if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids:
|
||||||
|
candidate_pids.append(pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
main_p = ProfileAuthManager.get_main_profile("antigravity")
|
||||||
|
if main_p and main_p not in candidate_pids:
|
||||||
|
candidate_pids.append(main_p)
|
||||||
|
|
||||||
|
standard_slots = (
|
||||||
|
["ag-orch-primary", "ag-orch-fallback"]
|
||||||
|
+ [f"ag-{i}" for i in range(1, 21)]
|
||||||
|
+ [f"ag-w{i}" for i in range(1, 11)]
|
||||||
|
)
|
||||||
|
for s in standard_slots:
|
||||||
|
if s not in candidate_pids:
|
||||||
|
candidate_pids.append(s)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agy_dir = get_hermes_home() / "agy_profiles"
|
||||||
|
if agy_dir.is_dir():
|
||||||
|
for sub in sorted(agy_dir.iterdir()):
|
||||||
|
if sub.is_dir() and sub.name not in candidate_pids:
|
||||||
|
candidate_pids.append(sub.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
target_pid = None
|
||||||
|
for cand in candidate_pids:
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", cand)
|
||||||
|
if st.get("authenticated") or ProfileAuthManager.load_profile_auth("antigravity", cand):
|
||||||
|
target_pid = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_pid:
|
||||||
|
target_pid = candidate_pids[0] if candidate_pids else "ag-orch-fallback"
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = discover_models(profile_id=target_pid)
|
||||||
if res:
|
if res:
|
||||||
return sorted(list(set(res.values()))), None
|
return sorted(list(set(res.values()))), None
|
||||||
return None, "Модели Google Antigravity не обнаружены"
|
return None, "Модели Google Antigravity не обнаружены"
|
||||||
|
|
|
||||||
180
src/antigravity_provider/router/model_entitlements.py
Normal file
180
src/antigravity_provider/router/model_entitlements.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Определение моделей, доступных конкретному аккаунту.
|
||||||
|
|
||||||
|
Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и
|
||||||
|
возвращает один и тот же список всем. Полей о доступности в нём нет — только
|
||||||
|
`id`, `object`, `created`, `owned_by`. Проверено запросом.
|
||||||
|
|
||||||
|
Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя.
|
||||||
|
Единственный достоверный способ — спросить у самой модели. Недоступная
|
||||||
|
отвечает до генерации:
|
||||||
|
|
||||||
|
404 Function ... Not found for account '<id>'
|
||||||
|
|
||||||
|
то есть проверка недоступной модели не стоит ничего, а доступной — один токен
|
||||||
|
при `max_tokens=1`.
|
||||||
|
|
||||||
|
Опрос запускается ТОЛЬКО по явному действию владельца и результат сохраняется:
|
||||||
|
83 запроса подряд упираются в ограничения провайдера, и делать это молча при
|
||||||
|
каждом подключении неправильно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from antigravity_provider import paths
|
||||||
|
|
||||||
|
logger = logging.getLogger("hermes.router.entitlements")
|
||||||
|
|
||||||
|
# Опрашиваем небольшими пачками: провайдеры ограничивают частоту, а выигрыш от
|
||||||
|
# большего числа потоков всё равно съедается их лимитом.
|
||||||
|
DEFAULT_WORKERS = 8
|
||||||
|
DEFAULT_TIMEOUT_SEC = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EntitlementResult:
|
||||||
|
"""Итог опроса одного аккаунта."""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
profile_id: str
|
||||||
|
checked_at: float
|
||||||
|
available: List[str] = field(default_factory=list)
|
||||||
|
unavailable: List[str] = field(default_factory=list)
|
||||||
|
undetermined: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"provider": self.provider,
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
"checked_at": self.checked_at,
|
||||||
|
"available": list(self.available),
|
||||||
|
"unavailable": list(self.unavailable),
|
||||||
|
"undetermined": dict(self.undetermined),
|
||||||
|
"total": len(self.available) + len(self.unavailable) + len(self.undetermined),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _store_path() -> Any:
|
||||||
|
return paths.get_hermes_home() / "model_entitlements.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_entitlements(provider: str, profile_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Ранее определённая доступность или None, если опрос не проводился."""
|
||||||
|
try:
|
||||||
|
path = _store_path()
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
return data.get(f"{provider}:{profile_id}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Не удалось прочитать сохранённую доступность: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_entitlements(result: EntitlementResult) -> None:
|
||||||
|
path = _store_path()
|
||||||
|
try:
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
data[f"{result.provider}:{result.profile_id}"] = result.to_dict()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Не удалось сохранить доступность моделей: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_one(base_url: str, token: str, model: str, timeout: float) -> tuple:
|
||||||
|
"""Вернуть (model, state, note): state — available | unavailable | undetermined."""
|
||||||
|
body = json.dumps(
|
||||||
|
{"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base_url.rstrip("/") + "/chat/completions",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
if isinstance(payload.get("choices"), list):
|
||||||
|
return (model, "available", "")
|
||||||
|
return (model, "undetermined", "провайдер вернул ответ без choices")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# 404 — модель аккаунту не выдана. Это ответ провайдера, а не сбой.
|
||||||
|
if exc.code == 404:
|
||||||
|
return (model, "unavailable", "не выдана аккаунту")
|
||||||
|
# 401/403 — отвергнут сам ключ: дальше опрашивать бессмысленно.
|
||||||
|
if exc.code in (401, 403):
|
||||||
|
return (model, "undetermined", f"ключ отвергнут (HTTP {exc.code})")
|
||||||
|
if exc.code == 429:
|
||||||
|
return (model, "undetermined", "превышена частота запросов")
|
||||||
|
return (model, "undetermined", f"HTTP {exc.code}")
|
||||||
|
except Exception as exc:
|
||||||
|
return (model, "undetermined", str(exc)[:80])
|
||||||
|
|
||||||
|
|
||||||
|
def probe_account_models(
|
||||||
|
provider: str,
|
||||||
|
profile_id: str,
|
||||||
|
token: str,
|
||||||
|
base_url: str,
|
||||||
|
models: List[str],
|
||||||
|
workers: int = DEFAULT_WORKERS,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT_SEC,
|
||||||
|
progress: Optional[Callable[[int, int], None]] = None,
|
||||||
|
) -> EntitlementResult:
|
||||||
|
"""Опросить каталог и разложить модели по доступности.
|
||||||
|
|
||||||
|
Модель, про которую ответ неоднозначен, попадает в `undetermined` с
|
||||||
|
причиной — выдавать её за доступную или недоступную нельзя.
|
||||||
|
"""
|
||||||
|
result = EntitlementResult(
|
||||||
|
provider=provider, profile_id=profile_id, checked_at=time.time()
|
||||||
|
)
|
||||||
|
if not models:
|
||||||
|
return result
|
||||||
|
if not token:
|
||||||
|
result.undetermined = {m: "ключ не задан" for m in models}
|
||||||
|
return result
|
||||||
|
|
||||||
|
done = 0
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def _run(model: str):
|
||||||
|
nonlocal done
|
||||||
|
outcome = _probe_one(base_url, token, model, timeout)
|
||||||
|
with lock:
|
||||||
|
done += 1
|
||||||
|
if progress:
|
||||||
|
try:
|
||||||
|
progress(done, len(models))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||||||
|
for model, state, note in pool.map(_run, models):
|
||||||
|
if state == "available":
|
||||||
|
result.available.append(model)
|
||||||
|
elif state == "unavailable":
|
||||||
|
result.unavailable.append(model)
|
||||||
|
else:
|
||||||
|
result.undetermined[model] = note
|
||||||
|
|
||||||
|
result.available.sort()
|
||||||
|
result.unavailable.sort()
|
||||||
|
save_entitlements(result)
|
||||||
|
return result
|
||||||
|
|
@ -15,7 +15,7 @@ import threading
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
@ -114,6 +114,76 @@ class ProfileAuthManager:
|
||||||
"""Official API to get isolated directory for a profile."""
|
"""Official API to get isolated directory for a profile."""
|
||||||
return get_profile_dir(profile_id, provider)
|
return get_profile_dir(profile_id, provider)
|
||||||
|
|
||||||
|
# agy 2.0 (Antigravity CLI) читает вход НЕ из .gemini/oauth_creds.json —
|
||||||
|
# это формат Gemini CLI. Свой токен он держит в
|
||||||
|
# .gemini/antigravity-cli/antigravity-oauth-token, и структура там другая:
|
||||||
|
# обёртка {"auth_method": ..., "token": {...}}.
|
||||||
|
#
|
||||||
|
# Хаб писал только файл Gemini CLI, поэтому авторизация проходила успешно, а
|
||||||
|
# agy отвечал «Please sign in to view available models»: он смотрел в файл,
|
||||||
|
# которого нет. Установлено сравнением рабочего профиля с неработающим.
|
||||||
|
# Значение взято из рабочего профиля владельца, не выведено из общих
|
||||||
|
# соображений: agy пишет туда "consumer" для личного аккаунта Google.
|
||||||
|
ANTIGRAVITY_AUTH_METHOD = "consumer"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_antigravity_cli_token(cls, profile_dir: Path, creds_dict: dict) -> Optional[Path]:
|
||||||
|
"""Записать токен в формате Antigravity CLI рядом с файлом Gemini CLI."""
|
||||||
|
cli_dir = profile_dir / ".gemini" / "antigravity-cli"
|
||||||
|
target = cli_dir / "antigravity-oauth-token"
|
||||||
|
try:
|
||||||
|
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(cli_dir, 0o700)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Способ входа сохраняем такой же, как у уже работающего профиля на
|
||||||
|
# этой машине: угадывать его значение нельзя, а рабочий образец
|
||||||
|
# рядом — самый надёжный источник.
|
||||||
|
auth_method = cls.ANTIGRAVITY_AUTH_METHOD
|
||||||
|
try:
|
||||||
|
for sibling in profile_dir.parent.iterdir():
|
||||||
|
if sibling == profile_dir or not sibling.is_dir():
|
||||||
|
continue
|
||||||
|
ref = sibling / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||||
|
if ref.is_file():
|
||||||
|
existing = json.loads(ref.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(existing, dict) and existing.get("auth_method"):
|
||||||
|
auth_method = str(existing["auth_method"])
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Срок годности пишем в обоих видах. Gemini CLI (Node) ждёт
|
||||||
|
# expiry_date в миллисекундах, Go-шный oauth2.Token — expiry
|
||||||
|
# строкой RFC3339. Какой из них читает agy, по бинарнику не
|
||||||
|
# определить, а лишнее поле разбор JSON пропускает.
|
||||||
|
token_payload = dict(creds_dict)
|
||||||
|
try:
|
||||||
|
expiry_ms = int(creds_dict.get("expiry_date") or 0)
|
||||||
|
if expiry_ms > 0:
|
||||||
|
token_payload["expiry"] = (
|
||||||
|
datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, OSError, OverflowError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = {"auth_method": auth_method, "token": token_payload}
|
||||||
|
temp = cli_dir / f"antigravity-oauth-token.tmp-{threading.get_ident()}-{time.time_ns()}"
|
||||||
|
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(temp, target)
|
||||||
|
try:
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return target
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Не записан токен Antigravity CLI в %s: %s", target, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
|
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
|
||||||
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
|
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
|
||||||
|
|
@ -123,6 +193,14 @@ class ProfileAuthManager:
|
||||||
|
|
||||||
access_token = token_info.get("access_token") or auth_data.get("access_token") or ""
|
access_token = token_info.get("access_token") or auth_data.get("access_token") or ""
|
||||||
refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
|
refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or ""
|
||||||
|
# Файл без токена доступа бесполезен: agy на нём отвечает «Please sign in
|
||||||
|
# to view available models», а хаб считает аккаунт подключённым. Пустой
|
||||||
|
# вход не должен выдаваться за успешный.
|
||||||
|
if not access_token and not refresh_token:
|
||||||
|
raise ValueError(
|
||||||
|
"Вход не завершён: провайдер не вернул токен доступа. "
|
||||||
|
"Учётные данные agy не записаны."
|
||||||
|
)
|
||||||
scope = token_info.get("scope") or auth_data.get("scope") or ""
|
scope = token_info.get("scope") or auth_data.get("scope") or ""
|
||||||
token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
|
token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer"
|
||||||
id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
|
id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
|
||||||
|
|
@ -179,6 +257,8 @@ class ProfileAuthManager:
|
||||||
os.chmod(target_file, 0o600)
|
os.chmod(target_file, 0o600)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
cls._write_antigravity_cli_token(profile_dir, creds_dict)
|
||||||
return target_file
|
return target_file
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -314,7 +394,16 @@ class ProfileAuthManager:
|
||||||
try:
|
try:
|
||||||
cls.write_agy_oauth_creds(pdir, auth_data)
|
cls.write_agy_oauth_creds(pdir, auth_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e)
|
# Прежде сбой оставался только в журнале, и владелец видел
|
||||||
|
# «подключено» при неработающем аккаунте. agy читает именно
|
||||||
|
# этот файл, поэтому без него подключения нет.
|
||||||
|
logger.error(
|
||||||
|
"Не записаны учётные данные agy для профиля %s: %s", profile_id, e
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Учётные данные для agy не записаны ({e}). "
|
||||||
|
f"Аккаунт {profile_id} подключённым не считается."
|
||||||
|
) from e
|
||||||
|
|
||||||
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
|
# For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml
|
||||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
|
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):
|
||||||
|
|
@ -423,6 +512,49 @@ class ProfileAuthManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error reading %s: %s", auth_file, e)
|
logger.warning("Error reading %s: %s", auth_file, e)
|
||||||
|
|
||||||
|
if not auth_file.is_file() and provider in ("antigravity", "google-antigravity"):
|
||||||
|
pdir = get_profile_dir(profile_id, provider)
|
||||||
|
cli_token = pdir / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||||
|
if cli_token.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(cli_token.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
tokens = data.get("token") or data
|
||||||
|
# Extract email if possible
|
||||||
|
email = None
|
||||||
|
acc_file = pdir / ".gemini" / "google_accounts.json"
|
||||||
|
if acc_file.is_file():
|
||||||
|
try:
|
||||||
|
acc_d = json.loads(acc_file.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(acc_d, dict) and acc_d.get("active") and "@" in str(acc_d["active"]):
|
||||||
|
email = str(acc_d["active"]).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not email and isinstance(tokens, dict):
|
||||||
|
id_tok = tokens.get("id_token")
|
||||||
|
if id_tok:
|
||||||
|
jwt_em, _ = cls.extract_jwt_identity(str(id_tok))
|
||||||
|
if jwt_em and "@" in jwt_em:
|
||||||
|
email = jwt_em
|
||||||
|
return {
|
||||||
|
"provider": provider,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"token": tokens,
|
||||||
|
"auth_method": data.get("auth_method", "consumer"),
|
||||||
|
"email": email or "",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading %s: %s", cli_token, e)
|
||||||
|
|
||||||
|
gemini_creds = pdir / ".gemini" / "oauth_creds.json"
|
||||||
|
if gemini_creds.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(gemini_creds.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {"provider": provider, "profile_id": profile_id, "token": data, "auth_method": "oauth"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading %s: %s", gemini_creds, e)
|
||||||
|
|
||||||
# Fallbacks for specific providers
|
# Fallbacks for specific providers
|
||||||
if provider == "openai-codex":
|
if provider == "openai-codex":
|
||||||
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
|
env_var = f"CODEX_TOKEN_{profile_id.upper().replace('-', '_')}"
|
||||||
|
|
@ -655,11 +787,24 @@ class ProfileAuthManager:
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider in ("antigravity", "google-antigravity"):
|
if provider in ("antigravity", "google-antigravity"):
|
||||||
tokens = auth_data.get("token") or auth_data.get("tokens", {})
|
tokens = auth_data.get("token") or auth_data.get("tokens")
|
||||||
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
|
if not isinstance(tokens, dict):
|
||||||
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
|
tokens = {}
|
||||||
refresh_tok = tokens.get("refresh_token") if isinstance(tokens, dict) else (auth_data.get("refresh_token") or "")
|
acc_token = tokens.get("access_token") or auth_data.get("access_token") or ""
|
||||||
email = auth_data.get("email")
|
id_token = tokens.get("id_token") or auth_data.get("id_token") or ""
|
||||||
|
refresh_tok = tokens.get("refresh_token") or auth_data.get("refresh_token") or ""
|
||||||
|
key = auth_data.get("api_key", "")
|
||||||
|
email = auth_data.get("email") or auth_data.get("user_email")
|
||||||
|
is_auth = bool(acc_token or refresh_tok or key or (email and auth_data.get("auth_method") == "oauth"))
|
||||||
|
if not is_auth:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"provider": provider,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"status": "NOT_CONFIGURED",
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
acc_id = None
|
acc_id = None
|
||||||
if id_token:
|
if id_token:
|
||||||
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
|
email_from_jwt, acc_id = cls.extract_jwt_identity(id_token)
|
||||||
|
|
@ -668,11 +813,14 @@ class ProfileAuthManager:
|
||||||
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
|
email_from_jwt, acc_id = cls.extract_jwt_identity(acc_token)
|
||||||
email = email or email_from_jwt
|
email = email or email_from_jwt
|
||||||
|
|
||||||
expiry = tokens.get("expiry_date") if isinstance(tokens, dict) else auth_data.get("expiry_date")
|
expiry = (
|
||||||
if not expiry and isinstance(tokens, dict):
|
tokens.get("expiry_date")
|
||||||
expiry = tokens.get("expires_at")
|
or auth_data.get("expiry_date")
|
||||||
|
or tokens.get("expires_at")
|
||||||
|
or auth_data.get("expires_at")
|
||||||
|
)
|
||||||
if not expiry:
|
if not expiry:
|
||||||
expiry_str = tokens.get("expiry") if isinstance(tokens, dict) else auth_data.get("expiry")
|
expiry_str = tokens.get("expiry") or auth_data.get("expiry")
|
||||||
if expiry_str:
|
if expiry_str:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
|
dt = datetime.fromisoformat(str(expiry_str).replace("Z", "+00:00"))
|
||||||
|
|
@ -688,7 +836,7 @@ class ProfileAuthManager:
|
||||||
is_expired = not bool(refresh_tok)
|
is_expired = not bool(refresh_tok)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": not is_expired,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
"email_masked": mask_email(email) if email else None,
|
"email_masked": mask_email(email) if email else None,
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,29 @@ class ProfileOAuthSession:
|
||||||
email = fetch_user_email(tokens["access_token"])
|
email = fetch_user_email(tokens["access_token"])
|
||||||
logger.info("OAuth account identity resolved (email_found=%s)", bool(email))
|
logger.info("OAuth account identity resolved (email_found=%s)", bool(email))
|
||||||
|
|
||||||
|
# Слот выбирается ДО входа, когда почта ещё неизвестна, поэтому
|
||||||
|
# повторный вход тем же аккаунтом занимал очередной свободный
|
||||||
|
# слот: у владельца один аккаунт расползся на ag-2, ag-3, ag-4.
|
||||||
|
# Узнав почту, возвращаем учётные данные в слот, который этот
|
||||||
|
# аккаунт уже занимает, вместо создания двойника.
|
||||||
|
if email:
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = AutoAssigner.check_duplicate_identity(
|
||||||
|
"antigravity", email, exclude_profile_id=self.profile_id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
existing = None
|
||||||
|
logger.warning("Проверка двойников не выполнена: %s", exc)
|
||||||
|
if existing and existing != self.profile_id:
|
||||||
|
logger.info(
|
||||||
|
"Аккаунт уже занимает профиль %s — пишем туда, а не в %s",
|
||||||
|
existing,
|
||||||
|
self.profile_id,
|
||||||
|
)
|
||||||
|
self.profile_id = existing
|
||||||
|
|
||||||
# Format in standard gemini:antigravity shape
|
# Format in standard gemini:antigravity shape
|
||||||
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
|
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
|
||||||
auth_data = {
|
auth_data = {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,19 @@ class AccountQuotaService:
|
||||||
# If not in cache, generate baseline snapshot from profile auth
|
# If not in cache, generate baseline snapshot from profile auth
|
||||||
return self._generate_baseline_snapshot(provider, profile_id)
|
return self._generate_baseline_snapshot(provider, profile_id)
|
||||||
|
|
||||||
|
def forget_profile(self, provider: str, profile_id: str) -> None:
|
||||||
|
"""Забыть опознание и квоты профиля.
|
||||||
|
|
||||||
|
_identities и _snapshots живут в памяти по ключу «провайдер:слот».
|
||||||
|
При удалении ключа они не чистились, и слот, переиспользованный под
|
||||||
|
другой аккаунт, показывал прежнюю почту: владелец удалил один
|
||||||
|
аккаунт Antigravity, завёл другой и увидел в списке старый адрес.
|
||||||
|
"""
|
||||||
|
key = f"{provider}:{profile_id}"
|
||||||
|
with self._cache_lock:
|
||||||
|
self._identities.pop(key, None)
|
||||||
|
self._snapshots.pop(key, None)
|
||||||
|
|
||||||
def get_identity(self, provider: str, profile_id: str) -> AccountIdentity:
|
def get_identity(self, provider: str, profile_id: str) -> AccountIdentity:
|
||||||
key = f"{provider}:{profile_id}"
|
key = f"{provider}:{profile_id}"
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
|
|
@ -905,6 +918,31 @@ class AccountQuotaService:
|
||||||
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
|
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
|
||||||
"""Truthful offline baseline with provider-specific independent limit pools."""
|
"""Truthful offline baseline with provider-specific independent limit pools."""
|
||||||
now = _utc_now()
|
now = _utc_now()
|
||||||
|
# У облачного аккаунта Ollama лимиты есть, и объявлять их
|
||||||
|
# отсутствующими нельзя. Локальный сервер узнаётся по отсутствию
|
||||||
|
# ключа: облачный доступ без ключа невозможен.
|
||||||
|
if provider == "ollama":
|
||||||
|
try:
|
||||||
|
from .profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
auth = ProfileAuthManager.load_profile_auth(provider, profile_id) or {}
|
||||||
|
is_cloud = bool((auth.get("api_key") or auth.get("token") or "").strip())
|
||||||
|
except Exception:
|
||||||
|
is_cloud = False
|
||||||
|
if is_cloud:
|
||||||
|
return QuotaSnapshot(
|
||||||
|
account_id=profile_id,
|
||||||
|
provider=provider,
|
||||||
|
buckets=[],
|
||||||
|
fetched_at=now,
|
||||||
|
source="cloud_provider",
|
||||||
|
unavailable_reason=(
|
||||||
|
"Н/Д: лимиты облачного аккаунта Ollama не измерены — "
|
||||||
|
"провайдер не сообщает их через API"
|
||||||
|
),
|
||||||
|
is_loading=False,
|
||||||
|
)
|
||||||
|
|
||||||
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
|
||||||
b_unlimited = QuotaBucket(
|
b_unlimited = QuotaBucket(
|
||||||
id="local.unlimited",
|
id="local.unlimited",
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,117 @@ 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."""
|
||||||
|
|
||||||
|
|
@ -206,7 +317,11 @@ 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 = Path(path).expanduser().resolve()
|
target, resolve_error = canonical_path(path)
|
||||||
|
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)
|
||||||
|
|
@ -220,7 +335,9 @@ 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 = Path(path).expanduser().resolve()
|
target, resolve_error = canonical_path(path)
|
||||||
|
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:
|
||||||
|
|
@ -252,10 +369,9 @@ class WorkspaceBoundaryGuard:
|
||||||
|
|
||||||
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
||||||
"""
|
"""
|
||||||
try:
|
target, resolve_error = canonical_path(path)
|
||||||
target = Path(path).expanduser().resolve()
|
if target is None:
|
||||||
except Exception as exc:
|
return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
|
||||||
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"}:
|
||||||
|
|
@ -290,12 +406,18 @@ 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:
|
||||||
# Windows and POSIX-compatible shlex split
|
tokens = shlex.split(cmd_line, posix=not windows_dialect)
|
||||||
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
|
||||||
|
|
@ -310,7 +432,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 (os.name == "nt" and arg.startswith("/") and len(arg) == 2):
|
if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
|
||||||
continue
|
continue
|
||||||
targets.append(arg)
|
targets.append(arg)
|
||||||
|
|
||||||
|
|
@ -321,17 +443,23 @@ 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 в окружении нет. Самый естественный
|
||||||
# написать опасную команду обходил защиту ровно там, ради
|
# способ написать опасную команду обходил защиту ровно там,
|
||||||
# чего она и делалась — на каталоге учётных данных.
|
# ради чего она и делалась — на каталоге учётных данных.
|
||||||
expanded = os.path.expandvars(os.path.expanduser(target_arg))
|
target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
|
||||||
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
|
if target_path is None:
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ along with Obsidian shared memory validation and canonical structure setup.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -32,6 +34,18 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"email_masking_mode": "none",
|
"email_masking_mode": "none",
|
||||||
"default_role": "manager",
|
"default_role": "manager",
|
||||||
"obsidian_vault_path": "/srv/projects/AI-Memory",
|
"obsidian_vault_path": "/srv/projects/AI-Memory",
|
||||||
|
"compressor_profile_id": None,
|
||||||
|
"compression_threshold_percent": 75.0,
|
||||||
|
"compression_keep_recent_messages": 3,
|
||||||
|
"compression_enabled": True,
|
||||||
|
# Прокси для обращений провайдеров, у которых проверка доступности
|
||||||
|
# смотрит на адрес. Google отказал владельцу: «not currently available
|
||||||
|
# in your location». Задаётся адресом вида socks5://127.0.0.1:1080 или
|
||||||
|
# http://127.0.0.1:8080; пустое значение означает «без прокси».
|
||||||
|
"provider_proxy_url": "",
|
||||||
|
# Путь к пользовательскому сценарию патча проверки доступности agy (A58).
|
||||||
|
# Пустое значение означает «сценарий не указан».
|
||||||
|
"agy_patch_script_path": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,6 +67,36 @@ def get_settings_file() -> Path:
|
||||||
return get_hermes_home() / "hub_settings.json"
|
return get_hermes_home() / "hub_settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_proxy_url(raw: Any) -> str:
|
||||||
|
"""Привести адрес прокси к рабочему виду или отбросить.
|
||||||
|
|
||||||
|
Проверяем не только схему, но и сам адрес: приписать socks5:// можно
|
||||||
|
чему угодно, и тогда мусор выглядел бы принятым, а все обращения
|
||||||
|
провайдера молча ломались бы без внятной причины.
|
||||||
|
"""
|
||||||
|
value = str(raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if "://" not in value:
|
||||||
|
value = "socks5://" + value
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
if parsed.scheme not in ("http", "https", "socks5", "socks5h"):
|
||||||
|
return ""
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host or not re.fullmatch(r"[A-Za-z0-9._\-\[\]:]+", host):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
if port is not None and not (1 <= port <= 65535):
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def get_hub_settings() -> Dict[str, Any]:
|
def get_hub_settings() -> Dict[str, Any]:
|
||||||
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
|
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
|
||||||
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
|
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
|
||||||
|
|
@ -124,6 +168,29 @@ def get_hub_settings() -> Dict[str, Any]:
|
||||||
vault_path = str(merged.get("obsidian_vault_path", "/srv/projects/AI-Memory")).strip()
|
vault_path = str(merged.get("obsidian_vault_path", "/srv/projects/AI-Memory")).strip()
|
||||||
merged["obsidian_vault_path"] = vault_path
|
merged["obsidian_vault_path"] = vault_path
|
||||||
|
|
||||||
|
compressor_pid = merged.get("compressor_profile_id")
|
||||||
|
if compressor_pid is not None and str(compressor_pid).strip() and str(compressor_pid).strip() != "none":
|
||||||
|
merged["compressor_profile_id"] = str(compressor_pid).strip()
|
||||||
|
else:
|
||||||
|
merged["compressor_profile_id"] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
merged["compression_threshold_percent"] = max(10.0, min(95.0, float(merged.get("compression_threshold_percent", 75.0))))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
merged["compression_threshold_percent"] = 75.0
|
||||||
|
|
||||||
|
# Адрес прокси проверяем на вид, а не принимаем что попало: неверная
|
||||||
|
# схема тихо ломает все обращения провайдера, и причина не видна.
|
||||||
|
merged["provider_proxy_url"] = _normalize_proxy_url(merged.get("provider_proxy_url"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
merged["compression_keep_recent_messages"] = max(1, min(20, int(merged.get("compression_keep_recent_messages", 3))))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
merged["compression_keep_recent_messages"] = 3
|
||||||
|
|
||||||
|
merged["compression_enabled"] = bool(merged.get("compression_enabled", True))
|
||||||
|
merged["agy_patch_script_path"] = str(merged.get("agy_patch_script_path") or "").strip()
|
||||||
|
|
||||||
_SETTINGS_CACHE = dict(merged)
|
_SETTINGS_CACHE = dict(merged)
|
||||||
_SETTINGS_CACHE_MTIME = current_mtime
|
_SETTINGS_CACHE_MTIME = current_mtime
|
||||||
_SETTINGS_CACHE_PATH = sfile_str
|
_SETTINGS_CACHE_PATH = sfile_str
|
||||||
|
|
@ -277,8 +344,46 @@ def setup_memory_structure(
|
||||||
def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||||||
"""Read Hermes configuration (~/.hermes/config.yaml) in read-only mode to provide status feedback."""
|
"""Read Hermes configuration (~/.hermes/config.yaml) in read-only mode to provide status feedback."""
|
||||||
import yaml
|
import yaml
|
||||||
|
checked: list = []
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
config_path = get_hermes_home() / "config.yaml"
|
# Hermes хранит конфигурацию не в одном месте: путь зависит от версии и
|
||||||
|
# способа установки. Раньше проверялся ровно один файл, и у владельца на
|
||||||
|
# Linux хаб писал «конфигурация не найдена» при работающем Hermes.
|
||||||
|
home = get_hermes_home()
|
||||||
|
candidates = [
|
||||||
|
home / "config.yaml",
|
||||||
|
home / "config.yml",
|
||||||
|
home / "config.json",
|
||||||
|
home / "hermes-agent" / "config.yaml",
|
||||||
|
Path.home() / ".hermes" / "config.yaml",
|
||||||
|
Path.home() / ".config" / "hermes" / "config.yaml",
|
||||||
|
Path.home() / ".config" / "hermes-agent" / "config.yaml",
|
||||||
|
]
|
||||||
|
seen = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
key = str(candidate)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
checked.append(key)
|
||||||
|
try:
|
||||||
|
if candidate.exists():
|
||||||
|
config_path = candidate
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
# Каталог может быть закрыт правами: это «не смогли проверить»,
|
||||||
|
# а не «файла нет». Разница важна, иначе диагноз ложный.
|
||||||
|
checked[-1] = f"{key} (нет доступа: {exc.strerror or exc})"
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"exists": False,
|
||||||
|
"model": None,
|
||||||
|
"provider": None,
|
||||||
|
"base_url": None,
|
||||||
|
"path": None,
|
||||||
|
"checked_paths": checked,
|
||||||
|
"message": "Конфигурация Hermes не найдена. Проверено: " + "; ".join(checked),
|
||||||
|
}
|
||||||
|
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return {
|
return {
|
||||||
|
|
@ -287,7 +392,8 @@ def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, An
|
||||||
"provider": None,
|
"provider": None,
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
"path": str(config_path),
|
"path": str(config_path),
|
||||||
"message": "Конфигурационный файл ~/.hermes/config.yaml не найден",
|
"checked_paths": checked or [str(config_path)],
|
||||||
|
"message": f"Конфигурация Hermes не найдена по пути {config_path}",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import time
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
|
|
||||||
from antigravity_provider.router.event_bus import (
|
from antigravity_provider.router.event_bus import (
|
||||||
EventBus,
|
EventBus,
|
||||||
EVENT_ACCOUNT_UPDATED,
|
EVENT_ACCOUNT_UPDATED,
|
||||||
|
|
@ -213,7 +215,9 @@ class HubStateStore:
|
||||||
hermes_cfg = {"exists": False, "model": None, "provider": None}
|
hermes_cfg = {"exists": False, "model": None, "provider": None}
|
||||||
default_role = "manager"
|
default_role = "manager"
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
metrics = {
|
metrics = {
|
||||||
|
"version": __version__,
|
||||||
"generation": gen,
|
"generation": gen,
|
||||||
"seq": request_seq,
|
"seq": request_seq,
|
||||||
"duration_ms": round((time.time() - t0) * 1000, 2),
|
"duration_ms": round((time.time() - t0) * 1000, 2),
|
||||||
|
|
@ -311,6 +315,7 @@ class HubStateStore:
|
||||||
routing={},
|
routing={},
|
||||||
quotas={},
|
quotas={},
|
||||||
metrics={
|
metrics={
|
||||||
|
"version": __version__,
|
||||||
"generation": 0,
|
"generation": 0,
|
||||||
"seq": 0,
|
"seq": 0,
|
||||||
"telemetry": telemetry_data,
|
"telemetry": telemetry_data,
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,11 @@ class TelemetryService:
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
# safe_load, а не safe_dump: dump сериализует текст обратно
|
||||||
|
# в строку, 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
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,25 @@ class UnifiedHealthService:
|
||||||
if check.get("state") == "checking":
|
if check.get("state") == "checking":
|
||||||
health_state, health_lbl = "checking", "Проверяется…"
|
health_state, health_lbl = "checking", "Проверяется…"
|
||||||
elif check.get("state") == "failed":
|
elif check.get("state") == "failed":
|
||||||
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
# Вердикт проверки и каталог моделей обновляются
|
||||||
|
# независимо, и в карточке владельца они оказались
|
||||||
|
# рядом: «не работает — Please sign in» и тут же
|
||||||
|
# «Получено 11 моделей» с более поздним временем.
|
||||||
|
# Утверждать отказ, когда каталог получен ПОЗЖЕ него,
|
||||||
|
# нельзя: провайдер с тех пор ответил. Но и объявлять
|
||||||
|
# аккаунт рабочим не за что — проверка после этого не
|
||||||
|
# выполнялась. Честный ответ — что вердикт устарел.
|
||||||
|
checked_at = check.get("checked_at") or 0
|
||||||
|
catalog_at = model_meta.get("discovered_at") or 0
|
||||||
|
if not model_meta.get("error") and catalog_at > checked_at:
|
||||||
|
when = datetime.datetime.fromtimestamp(catalog_at).strftime("%H:%M:%S")
|
||||||
|
health_state = STATUS_NOT_TESTED
|
||||||
|
health_lbl = (
|
||||||
|
f"Проверка устарела: каталог моделей получен позже ({when}). "
|
||||||
|
"Нажмите «Проверить подключение»."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д")
|
||||||
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
|
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
|
||||||
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
|
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
|
||||||
assigned = role_assignments.get(pid, [])
|
assigned = role_assignments.get(pid, [])
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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__
|
||||||
|
|
@ -23,7 +24,13 @@ 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 get_installed_commit, UpdateManager
|
from antigravity_provider.updater.update_manager import (
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
@ -99,6 +106,76 @@ 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() читает манифест с диска при каждом вызове, поэтому
|
||||||
|
# переживший обновление процесс бодро рапортует свежий коммит: владелец видит
|
||||||
|
# новый номер и старое поведение. Отличить сборки по такому полю нельзя.
|
||||||
|
#
|
||||||
|
# Снятое при старте значение отвечает на настоящий вопрос: какой код сейчас
|
||||||
|
# в памяти. Вместе со временем запуска этого достаточно, чтобы понять, дошло
|
||||||
|
# ли обновление до работы, а не только до файлов.
|
||||||
|
RUNNING_COMMIT: str = get_installed_commit()
|
||||||
|
PROCESS_STARTED_AT: float = time.time()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
from ..account_probe_service import AccountProbeService
|
from ..account_probe_service import AccountProbeService
|
||||||
|
|
@ -108,6 +185,8 @@ def health_check():
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"commit": get_installed_commit(),
|
"commit": get_installed_commit(),
|
||||||
|
"running_commit": RUNNING_COMMIT,
|
||||||
|
"started_at": PROCESS_STARTED_AT,
|
||||||
# Antigravity и Claude раньше стояли здесь как supported: False с
|
# Antigravity и Claude раньше стояли здесь как supported: False с
|
||||||
# советом идти в десктоп или пробрасывать порты. Это было неверно:
|
# советом идти в десктоп или пробрасывать порты. Это было неверно:
|
||||||
# ProfileOAuthSession.handle_manual_callback_url и
|
# ProfileOAuthSession.handle_manual_callback_url и
|
||||||
|
|
@ -119,8 +198,8 @@ def health_check():
|
||||||
"opencode-go": {"supported": True, "reason": "token"},
|
"opencode-go": {"supported": True, "reason": "token"},
|
||||||
"antigravity": {
|
"antigravity": {
|
||||||
"supported": True,
|
"supported": True,
|
||||||
"reason": "redirect-url-paste",
|
"reason": "terminal-or-redirect",
|
||||||
"hint": "Откройте ссылку в любом браузере и верните адрес из адресной строки",
|
"hint": "Вход через agy в терминале (основной) или по ссылке в браузере (запасной)",
|
||||||
},
|
},
|
||||||
"claude": {
|
"claude": {
|
||||||
"supported": True,
|
"supported": True,
|
||||||
|
|
@ -234,10 +313,39 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snap_dict["version"] = __version__
|
||||||
|
snap_dict["commit"] = get_installed_commit()
|
||||||
|
snap_dict["running_commit"] = RUNNING_COMMIT
|
||||||
|
snap_dict["started_at"] = PROCESS_STARTED_AT
|
||||||
|
snap_dict["system_paths"] = {
|
||||||
|
"hermes_home": str(paths.get_hermes_home()),
|
||||||
|
"config_dir": str(paths.get_config_dir()),
|
||||||
|
"log_file": str(paths.get_log_file()),
|
||||||
|
}
|
||||||
|
snap_dict["last_applied_update"] = get_last_applied_update()
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
|
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||||||
|
except Exception as exc:
|
||||||
|
snap_dict["agy_eligibility"] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"status_label_ru": f"Н/Д: {exc}",
|
||||||
|
"detail_ru": str(exc),
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": "",
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": 0,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": "",
|
||||||
|
}
|
||||||
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(request: Request, authorized: bool = Depends(get_auth_token)):
|
async def handle_action(
|
||||||
|
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:
|
||||||
|
|
@ -310,7 +418,11 @@ 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(request: Request, authorized: bool = Depends(get_auth_token)):
|
async def assign_skill_endpoint(
|
||||||
|
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:
|
||||||
|
|
@ -331,7 +443,11 @@ async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/skills/unassign")
|
@app.post("/api/skills/unassign")
|
||||||
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
async def unassign_skill_endpoint(
|
||||||
|
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:
|
||||||
|
|
@ -360,7 +476,11 @@ 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(request: Request, authorized: bool = Depends(get_auth_token)):
|
async def diagnose_skill_endpoint(
|
||||||
|
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:
|
||||||
|
|
@ -379,6 +499,89 @@ async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(g
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/compression/status")
|
||||||
|
def get_compression_status_endpoint(authorized: bool = Depends(get_auth_token)):
|
||||||
|
"""Return real-time diagnostic status of context compressor."""
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
|
||||||
|
hub_settings = get_hub_settings()
|
||||||
|
c_pid = hub_settings.get("compressor_profile_id")
|
||||||
|
c_pcfg = None
|
||||||
|
if c_pid:
|
||||||
|
try:
|
||||||
|
c_pcfg = load_router_config().get_profile(c_pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
supervisor = LocalSupervisor()
|
||||||
|
status_data = supervisor.get_compression_status(c_pcfg)
|
||||||
|
status_data["threshold_percent"] = hub_settings.get("compression_threshold_percent", 75.0)
|
||||||
|
status_data["keep_recent_messages"] = hub_settings.get("compression_keep_recent_messages", 3)
|
||||||
|
status_data["compression_enabled"] = hub_settings.get("compression_enabled", True)
|
||||||
|
return JSONResponse(content=jsonable_encoder(status_data))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/compression/history")
|
||||||
|
def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends(get_auth_token)):
|
||||||
|
"""Return historical record of recent context compressions."""
|
||||||
|
from antigravity_provider.router.context_compressor import ContextCompressor
|
||||||
|
history = ContextCompressor().get_compression_history(limit=limit)
|
||||||
|
return JSONResponse(content=jsonable_encoder({"history": history}))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compression/test")
|
||||||
|
async def test_compression_endpoint(
|
||||||
|
request: Request,
|
||||||
|
authorized: bool = Depends(get_auth_token),
|
||||||
|
same_origin: bool = Depends(require_same_origin),
|
||||||
|
):
|
||||||
|
"""Execute test context compression on synthetic benchmark prompt."""
|
||||||
|
from antigravity_provider.router.settings_service import get_hub_settings
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
hub_settings = get_hub_settings()
|
||||||
|
c_pid = data.get("profile_id") or hub_settings.get("compressor_profile_id")
|
||||||
|
c_pcfg = None
|
||||||
|
if c_pid and c_pid != "none":
|
||||||
|
try:
|
||||||
|
c_pcfg = load_router_config().get_profile(c_pid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
test_messages = [
|
||||||
|
{"role": "system", "content": "You are a software engineer."},
|
||||||
|
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. The primary coder is on port 8081 with 224K context (229376 tokens), generating at 107.4 tok/s. VRAM usage: 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082."},
|
||||||
|
{"role": "assistant", "content": "Acknowledged. All server metrics and ports are noted."},
|
||||||
|
{"role": "user", "content": "Now run preflight diagnostics for LocalSupervisor and ContextCompressor in src/antigravity_provider/router/local_supervisor.py."},
|
||||||
|
{"role": "assistant", "content": "Diagnostics completed successfully. Memory vault is at /srv/projects/AI-Memory."},
|
||||||
|
{"role": "user", "content": "What is our current task?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
supervisor = LocalSupervisor()
|
||||||
|
new_msgs, outcome = supervisor.compress_context_if_needed(
|
||||||
|
messages=test_messages,
|
||||||
|
target_context_limit=32768,
|
||||||
|
compressor_profile=c_pcfg,
|
||||||
|
threshold_percent=0.0, # force compression
|
||||||
|
keep_recent_messages=2,
|
||||||
|
)
|
||||||
|
return JSONResponse(content=jsonable_encoder({
|
||||||
|
"ok": outcome.status == "SUCCESS",
|
||||||
|
"message": outcome.status_message,
|
||||||
|
"data": {
|
||||||
|
"outcome": asdict(outcome) if hasattr(outcome, "__dataclass_fields__") else outcome.__dict__,
|
||||||
|
"messages_before_count": len(test_messages),
|
||||||
|
"messages_after_count": len(new_msgs),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/settings")
|
@app.get("/api/settings")
|
||||||
def get_settings(authorized: bool = Depends(get_auth_token)):
|
def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
"""Return current server and hub settings without exposing raw auth tokens."""
|
"""Return current server and hub settings without exposing raw auth tokens."""
|
||||||
|
|
@ -387,18 +590,29 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
last_check = UpdateManager.get_last_check_result()
|
last_check = UpdateManager.get_last_check_result()
|
||||||
server_host = raw.get("web_api_host", "127.0.0.1")
|
server_host = raw.get("web_api_host", "127.0.0.1")
|
||||||
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
|
||||||
|
system_paths = {
|
||||||
|
"hermes_home": str(paths.get_hermes_home()),
|
||||||
|
"config_dir": str(paths.get_config_dir()),
|
||||||
|
"log_file": str(paths.get_log_file()),
|
||||||
|
}
|
||||||
settings_out: Dict[str, Any] = {
|
settings_out: Dict[str, Any] = {
|
||||||
|
"system_paths": system_paths,
|
||||||
"web_api_host": server_host,
|
"web_api_host": server_host,
|
||||||
"web_api_port": raw.get("web_api_port", 5800),
|
"web_api_port": raw.get("web_api_port", 5800),
|
||||||
"web_api_token_configured": has_token,
|
"web_api_token_configured": has_token,
|
||||||
"theme": raw.get("theme", "system"),
|
"theme": raw.get("theme", "system"),
|
||||||
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300),
|
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", raw.get("account_check_interval_seconds", 300)),
|
||||||
"hermes_home": str(paths.get_hermes_home()),
|
"hermes_home": system_paths["hermes_home"],
|
||||||
"config_dir": str(paths.get_config_dir()),
|
"config_dir": system_paths["config_dir"],
|
||||||
"log_file": str(paths.get_log_file()),
|
"log_file": system_paths["log_file"],
|
||||||
"installed_commit": get_installed_commit(),
|
"installed_commit": get_installed_commit(),
|
||||||
|
# Версия между сборками не меняется намеренно, а коммит — строка из
|
||||||
|
# шестнадцатеричных цифр, по которой трудно на глаз отличить старую
|
||||||
|
# сборку от новой. Время установки отвечает на этот вопрос сразу.
|
||||||
|
"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,
|
||||||
|
|
@ -410,6 +624,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||||
|
settings_out["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||||||
|
except Exception as exc:
|
||||||
|
settings_out["agy_eligibility"] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"status_label_ru": f"Н/Д: {exc}",
|
||||||
|
"detail_ru": str(exc),
|
||||||
|
"version": "Н/Д",
|
||||||
|
"binary_path": "",
|
||||||
|
"binary_sha256": "",
|
||||||
|
"binary_size_bytes": 0,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
"patch_script_path": "",
|
||||||
|
}
|
||||||
for k, v in raw.items():
|
for k, v in raw.items():
|
||||||
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
|
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
|
||||||
settings_out[k] = v
|
settings_out[k] = v
|
||||||
|
|
@ -494,6 +723,23 @@ if _STATIC_DIR.is_dir():
|
||||||
headers=_NO_CACHE,
|
headers=_NO_CACHE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A48 и A49 добавили новые файлы клиента, а заголовки кэширования им не
|
||||||
|
# прописали: браузер держал их сколько угодно. Владелец обновлял сборку и
|
||||||
|
# видел прежний интерфейс — ровно та поломка, ради которой запрет кэша
|
||||||
|
# вводился для app.js. Перечисление сделано общим, чтобы следующий
|
||||||
|
# добавленный файл не оказался снова без заголовков.
|
||||||
|
for _client_script in ("workspace.js", "workflow.js", "workflow.css"):
|
||||||
|
|
||||||
|
def _make_route(name: str):
|
||||||
|
media = "text/css" if name.endswith(".css") else "application/javascript"
|
||||||
|
|
||||||
|
def _serve():
|
||||||
|
return FileResponse(str(_STATIC_DIR / name), media_type=media, headers=_NO_CACHE)
|
||||||
|
|
||||||
|
return _serve
|
||||||
|
|
||||||
|
app.get("/" + _client_script)(_make_route(_client_script))
|
||||||
|
|
||||||
@app.get("/snapshot.example.json")
|
@app.get("/snapshot.example.json")
|
||||||
def _fixture():
|
def _fixture():
|
||||||
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")
|
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")
|
||||||
|
|
@ -539,6 +785,25 @@ 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()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -66,7 +66,7 @@
|
||||||
<span class="status-dot healthy"></span>
|
<span class="status-dot healthy"></span>
|
||||||
<span class="source-text" id="source-text">Загрузка данных...</span>
|
<span class="source-text" id="source-text">Загрузка данных...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-tag" id="version-tag">Hermes Hub Web v0.1.1</div>
|
<div class="version-tag" id="version-tag">Hermes Hub Web</div>
|
||||||
<div class="commit-tag" id="commit-tag" style="font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:2px;">—</div>
|
<div class="commit-tag" id="commit-tag" style="font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:2px;">—</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -455,6 +455,13 @@
|
||||||
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label" for="setting-provider-proxy-url">Прокси / VPN для провайдеров</label>
|
||||||
|
<div class="setting-desc">Google отказывает по местоположению: «not currently available in your location». Выход через разрешённую страну снимает отказ. Пример: <code>socks5://127.0.0.1:1080</code>. Пусто — без прокси. Отдельному аккаунту можно задать свой адрес.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control"><input id="setting-provider-proxy-url" class="input-text" type="text" placeholder="Н/Д: загрузка" aria-label="Адрес прокси для обращений провайдеров"></div>
|
||||||
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>
|
<label class="setting-label" for="setting-account-check-interval">Проверка аккаунтов и моделей</label>
|
||||||
|
|
@ -594,13 +601,114 @@
|
||||||
<div id="obsidian-vault-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
|
<div id="obsidian-vault-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Context Compression Settings (A56) -->
|
||||||
|
<div class="settings-card" style="margin-top:16px;" id="settings-compression-card">
|
||||||
|
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<h2 class="settings-group-title" style="margin:0;">Сжатие контекста (Context Compression)</h2>
|
||||||
|
<div class="setting-desc">Автоматическое сжатие длинного контекста с сохранением 100% технических фактов и сущностей</div>
|
||||||
|
</div>
|
||||||
|
<span id="compression-status-badge" class="badge">Н/Д: модель для сжатия не выбрана</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Профиль модели для сжатия</div>
|
||||||
|
<div class="setting-desc">Служебная модель-компрессор (например, Qwen3-4B на порту 8082). Не участвует в маршрутизации Hermes.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select id="setting-compressor-profile" class="select-filter">
|
||||||
|
<option value="none">Н/Д: модель для сжатия не выбрана (отключено)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Порог заполнения контекста для запуска сжатия</div>
|
||||||
|
<div class="setting-desc">Сжатие начинается при превышении процента от измеренного контекста модели (/props)</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select id="setting-compression-threshold-percent" class="select-filter">
|
||||||
|
<option value="50">50% контекста (агрессивное сжатие)</option>
|
||||||
|
<option value="60">60% контекста</option>
|
||||||
|
<option value="70">70% контекста</option>
|
||||||
|
<option value="75" selected>75% контекста (рекомендуется — около 3/4)</option>
|
||||||
|
<option value="80">80% контекста</option>
|
||||||
|
<option value="85">85% контекста</option>
|
||||||
|
<option value="90">90% контекста (позднее сжатие)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Количество свежих сообщений (Fresh Window)</div>
|
||||||
|
<div class="setting-desc">Последние сообщения, которые всегда остаются 100% дословными и не подлежат сжатию</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select id="setting-compression-keep-recent" class="select-filter">
|
||||||
|
<option value="2">2 последних сообщения</option>
|
||||||
|
<option value="3" selected>3 последних сообщения (рекомендуется)</option>
|
||||||
|
<option value="4">4 последних сообщения</option>
|
||||||
|
<option value="5">5 последних сообщений</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Диагностика и проверка сжатия</div>
|
||||||
|
<div class="setting-desc" id="compression-diagnostic-desc">Проверить отклик компрессора и 100% удержание путей, портов, IP и хэшей</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-check-compression-status">Статус компрессора</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-test-compression">Тестовое сжатие (100% фактов)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="compression-details-box" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono); white-space:pre-wrap;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antigravity CLI & Eligibility Settings (A58) -->
|
||||||
|
<div class="settings-card" style="margin-top:16px;" id="settings-agy-card">
|
||||||
|
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<h2 class="settings-group-title" style="margin:0;">Параметры Antigravity CLI</h2>
|
||||||
|
<div class="setting-desc">Версия утилиты, путь к исполняемому файлу и состояние проверки доступности Google</div>
|
||||||
|
</div>
|
||||||
|
<span id="agy-eligibility-badge" class="badge">Проверка...</span>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Версия и исполняемый файл agy</div>
|
||||||
|
<div class="setting-desc" id="agy-version-path-desc">Н/Д: определение...</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="display:flex; gap:8px;">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-refresh-agy-eligibility">↻ Проверить состояние</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-run-agy-update" title="Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.">⬆ Обновить agy (agy update)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-top:8px;">
|
||||||
|
<div class="setting-desc" style="font-size:12px; color:var(--text-secondary); margin:0;">
|
||||||
|
ℹ️ <strong>Порядок действий:</strong> <em>Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="margin-top:12px;">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label class="setting-label" for="setting-agy-patch-script-path">Путь к сценарию патча владельца</label>
|
||||||
|
<div class="setting-desc">Собственный скрипт владельца для снятия проверки доступности (P0-4). Оставьте пустым, если не используется.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control" style="display:flex; gap:8px; width:55%;">
|
||||||
|
<input type="text" id="setting-agy-patch-script-path" class="input-text" style="flex:1;" placeholder="Н/Д: сценарий не указан">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-run-agy-patch-script">⚡ Запустить сценарий патча</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="agy-eligibility-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hub Updates -->
|
<!-- Hub Updates -->
|
||||||
<div class="settings-card" style="margin-top:16px;" id="settings-updates-card">
|
<div class="settings-card" style="margin-top:16px;" id="settings-updates-card">
|
||||||
<h2 class="settings-group-title">Обновление Hermes Hub</h2>
|
<h2 class="settings-group-title">Обновление Hermes Hub</h2>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div class="setting-label">Текущая версия и сборка</div>
|
<div class="setting-label">Текущая версия и сборка</div>
|
||||||
<div class="setting-desc" id="update-installed-info">Hermes Hub v0.1.1</div>
|
<div class="setting-desc" id="update-installed-info">Hermes Hub</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
|
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
|
||||||
<span id="update-status-badge" class="badge">Не проверено</span>
|
<span id="update-status-badge" class="badge">Не проверено</span>
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,17 @@ body {
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-eligibility-warning {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(230, 162, 60, 0.12);
|
||||||
|
border-left: 3px solid var(--status-warning);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--status-warning);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.role-answering-status {
|
.role-answering-status {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|
|
||||||
|
|
@ -229,14 +229,32 @@ function arrangeSettingsPanels() {
|
||||||
['Общие настройки',['setting-default-role','setting-theme']],
|
['Общие настройки',['setting-default-role','setting-theme']],
|
||||||
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
|
['Управление квотами',['setting-account-check-interval','setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
|
||||||
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
|
||||||
|
['Сеть и доступ',['setting-provider-proxy-url']],
|
||||||
];
|
];
|
||||||
for (const [title,ids] of groups) {
|
for (const [title,ids] of groups) {
|
||||||
const card = document.createElement('section'); card.className='settings-card';
|
const card = document.createElement('section'); card.className='settings-card';
|
||||||
const heading=document.createElement('h2'); heading.className='settings-group-title'; heading.textContent=title; card.append(heading);
|
const heading=document.createElement('h2'); heading.className='settings-group-title'; heading.textContent=title; card.append(heading);
|
||||||
for (const id of ids) card.append(document.getElementById(id).closest('.setting-row'));
|
for (const id of ids) {
|
||||||
|
const row = document.getElementById(id)?.closest('.setting-row');
|
||||||
|
if (row) card.append(row);
|
||||||
|
}
|
||||||
if (title === 'Управление квотами') card.append(document.getElementById('btn-save-hub-settings').closest('.settings-actions'));
|
if (title === 'Управление квотами') card.append(document.getElementById('btn-save-hub-settings').closest('.settings-actions'));
|
||||||
view.insertBefore(card, first);
|
view.insertBefore(card, first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строки, которых нет ни в одной группе, раньше удалялись вместе с исходной
|
||||||
|
// карточкой: новая настройка просто пропадала с экрана, и найти её было
|
||||||
|
// нельзя. Так исчезло поле прокси. Теперь остаток не выбрасывается, а
|
||||||
|
// собирается отдельной карточкой — забыть настройку в списке всё ещё можно,
|
||||||
|
// но потерять её уже нет.
|
||||||
|
const leftovers = Array.from(first.querySelectorAll('.setting-row'));
|
||||||
|
if (leftovers.length) {
|
||||||
|
const card = document.createElement('section'); card.className='settings-card';
|
||||||
|
const heading=document.createElement('h2'); heading.className='settings-group-title';
|
||||||
|
heading.textContent='Прочие настройки'; card.append(heading);
|
||||||
|
for (const row of leftovers) card.append(row);
|
||||||
|
view.insertBefore(card, first);
|
||||||
|
}
|
||||||
first.remove();
|
first.remove();
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', arrangeSettingsPanels);
|
document.addEventListener('DOMContentLoaded', arrangeSettingsPanels);
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,29 @@ 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 threading
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -72,8 +77,6 @@ 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"))
|
||||||
|
|
||||||
|
|
@ -168,6 +171,30 @@ 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
|
||||||
|
|
@ -180,6 +207,7 @@ 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
|
||||||
|
|
@ -194,6 +222,8 @@ 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
|
||||||
|
|
@ -211,6 +241,8 @@ 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,
|
||||||
|
|
@ -268,9 +300,226 @@ 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
|
||||||
|
|
||||||
|
|
@ -285,6 +534,85 @@ 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:
|
||||||
|
|
@ -302,6 +630,8 @@ 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,
|
||||||
|
|
@ -392,13 +722,16 @@ 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}
|
# Extract assets mapping {name: download_url} and asset sizes {name: size_bytes}
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -420,8 +753,27 @@ 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()
|
||||||
|
|
@ -463,6 +815,8 @@ 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,
|
||||||
|
|
@ -490,17 +844,45 @@ class UpdateManager:
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
dest_file: Path,
|
dest_file: Path,
|
||||||
progress_cb: Optional[Callable[[float], None]] = None,
|
progress_cb: Optional[Callable] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper to download a file with allowlist check and optional progress callback."""
|
"""Helper to download a file with allowlist check, progress tracking, and cancellation support."""
|
||||||
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:
|
||||||
progress_cb(1.0)
|
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
||||||
return
|
return
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
|
|
@ -508,21 +890,53 @@ 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:
|
||||||
total_len = int(resp.headers.get("content-length", 0))
|
raw_len = resp.headers.get("content-length")
|
||||||
|
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 chunk := resp.read(65536):
|
while True:
|
||||||
|
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)
|
||||||
if progress_cb and total_len > 0:
|
pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
|
||||||
progress_cb(downloaded / total_len)
|
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:
|
||||||
|
_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[[float], None]] = None,
|
progress_cb: Optional[Callable] = 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"
|
||||||
|
|
||||||
|
|
@ -530,31 +944,54 @@ 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)
|
||||||
return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None
|
err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}"
|
||||||
|
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)
|
||||||
return False, f"Ошибка загрузки: {exc}", None
|
err = f"Ошибка загрузки: {exc}"
|
||||||
|
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[[float], None]] = None,
|
progress_cb: Optional[Callable] = 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)
|
||||||
|
|
@ -573,6 +1010,8 @@ 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)
|
||||||
|
|
||||||
|
|
@ -592,6 +1031,19 @@ 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():
|
||||||
|
|
@ -605,17 +1057,30 @@ 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:
|
||||||
return False, "В релизе не найден подходящий файл обновления для текущей платформы"
|
err = "В релизе не найден подходящий файл обновления для текущей платформы"
|
||||||
|
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)
|
||||||
return False, f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
err = 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:
|
||||||
|
|
@ -627,26 +1092,59 @@ class UpdateManager:
|
||||||
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
||||||
if not expected_sha:
|
if not expected_sha:
|
||||||
dest_file.unlink(missing_ok=True)
|
dest_file.unlink(missing_ok=True)
|
||||||
return (
|
err = (
|
||||||
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)
|
||||||
return (
|
err = (
|
||||||
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":
|
||||||
|
|
@ -659,15 +1157,25 @@ class UpdateManager:
|
||||||
try:
|
try:
|
||||||
rc = proc.wait(timeout=600)
|
rc = proc.wait(timeout=600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
err = "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
||||||
|
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||||
|
return False, err
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
|
err = 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:
|
||||||
return False, f"Не удалось запустить установщик: {exc}"
|
err = 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:
|
||||||
|
|
@ -680,18 +1188,32 @@ 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:]
|
||||||
return False, "Установка не удалась: " + " / ".join(tail)
|
if 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:
|
||||||
return False, f"Не удалось запустить скрипт установки: {exc}"
|
err = 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]:
|
||||||
"""Перезапустить веб-хаб после установки обновления.
|
"""Перезапустить веб-хаб после установки обновления.
|
||||||
|
|
||||||
|
|
@ -741,8 +1263,6 @@ 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)
|
||||||
|
|
@ -758,8 +1278,15 @@ 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:
|
||||||
zf.extractall(dest)
|
_extract_within(zf, 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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Single Source of Truth for Hermes Hub Versioning."""
|
"""Single Source of Truth for Hermes Hub Versioning."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.1.1"
|
__version__ = "0.1.3"
|
||||||
VERSION_INFO = (0, 1, 1)
|
VERSION_INFO = (0, 1, 3)
|
||||||
CHANNEL = "stable"
|
CHANNEL = "stable"
|
||||||
MINIMUM_HERMES_VERSION = "0.20.0"
|
MINIMUM_HERMES_VERSION = "0.20.0"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,3 +396,150 @@ 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,7 +249,14 @@ 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
|
||||||
|
|
||||||
|
|
@ -259,6 +266,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)], env=env, capture_output=True, text=True)
|
proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ def test_p0_1_add_account_honest_rejections():
|
||||||
# Missing API key for claude
|
# Missing API key for claude
|
||||||
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
|
res_cl = ActionExecutor.execute("add_account", {"provider": "claude", "token": ""})
|
||||||
assert res_cl["ok"] is False
|
assert res_cl["ok"] is False
|
||||||
assert "API-ключ" in res_cl["message"]
|
assert "Авторизация через браузер не завершена" in res_cl["message"] or "API-ключ" in res_cl["message"]
|
||||||
|
|
||||||
# Unsupported provider
|
# Unsupported provider
|
||||||
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
|
res_unsupp = ActionExecutor.execute("add_account", {"provider": "unknown_provider_xyz"})
|
||||||
|
|
|
||||||
192
tests/test_a55_account_connection.py
Normal file
192
tests/test_a55_account_connection.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""Tests for Task A55: Account Connection Defect Fixes (P0-1 through P0-5)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.version import __version__, VERSION_INFO
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.adapters.ollama_adapter import OllamaAdapter, DEFAULT_OLLAMA_BASE_URL
|
||||||
|
from antigravity_provider.router.connection_preflight import validate_connection
|
||||||
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-1: Antigravity & Browser Auth Tests ──
|
||||||
|
|
||||||
|
def test_p0_1_add_account_unauthenticated_browser_auth_error_message():
|
||||||
|
"""add_account should return Russian browser auth message for antigravity and claude."""
|
||||||
|
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": False}):
|
||||||
|
# Antigravity without token/completed auth
|
||||||
|
res_ag = ActionExecutor.execute("add_account", {
|
||||||
|
"provider": "antigravity",
|
||||||
|
"profile_id": "ag-1",
|
||||||
|
"token": "",
|
||||||
|
})
|
||||||
|
assert not res_ag["ok"]
|
||||||
|
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_ag["message"]
|
||||||
|
|
||||||
|
# Claude without token/completed auth
|
||||||
|
res_cl = ActionExecutor.execute("add_account", {
|
||||||
|
"provider": "claude",
|
||||||
|
"profile_id": "claude-1",
|
||||||
|
"token": "",
|
||||||
|
})
|
||||||
|
assert not res_cl["ok"]
|
||||||
|
assert "Авторизация через браузер не завершена. Пожалуйста, откройте ссылку входа или вставьте адрес возврата." in res_cl["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_1_profile_auth_manager_get_profile_status_antigravity():
|
||||||
|
"""get_profile_status should return authenticated: False when auth_data is empty or invalid."""
|
||||||
|
# Unauthenticated/empty auth_data
|
||||||
|
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={}):
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
|
||||||
|
assert st["authenticated"] is False
|
||||||
|
assert st["status"] == "NOT_CONFIGURED"
|
||||||
|
|
||||||
|
# Authenticated with access_token
|
||||||
|
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": {"access_token": "ya29.valid-token"}}):
|
||||||
|
st = ProfileAuthManager.get_profile_status("antigravity", "ag-1")
|
||||||
|
assert st["authenticated"] is True
|
||||||
|
assert st["status"] == "AUTHENTICATED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_1_load_profile_auth_gemini_oauth_creds_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""load_profile_auth should check .gemini/oauth_creds.json if auth.json is missing."""
|
||||||
|
monkeypatch.setattr("antigravity_provider.paths.get_hermes_home", lambda: tmp_path)
|
||||||
|
pdir = tmp_path / "agy_profiles" / "ag-5"
|
||||||
|
gemini_dir = pdir / ".gemini"
|
||||||
|
gemini_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
creds_file = gemini_dir / "oauth_creds.json"
|
||||||
|
creds_file.write_text(json.dumps({"access_token": "token-123", "refresh_token": "refresh-456"}), encoding="utf-8")
|
||||||
|
|
||||||
|
auth = ProfileAuthManager.load_profile_auth("antigravity", "ag-5")
|
||||||
|
assert auth is not None
|
||||||
|
assert auth.get("provider") == "antigravity"
|
||||||
|
assert auth.get("token", {}).get("access_token") == "token-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-2: Ollama Base URL & Connection Error Tests ──
|
||||||
|
|
||||||
|
def test_p0_2_ollama_default_base_url():
|
||||||
|
"""DEFAULT_OLLAMA_BASE_URL must be http://127.0.0.1:11434 (without trailing /v1)."""
|
||||||
|
assert DEFAULT_OLLAMA_BASE_URL == "http://127.0.0.1:11434"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_2_ollama_adapter_url_builders():
|
||||||
|
"""OllamaAdapter URL methods should handle base_urls with or without /v1 cleanly."""
|
||||||
|
adapter = OllamaAdapter()
|
||||||
|
|
||||||
|
# Standard base url without /v1
|
||||||
|
assert adapter._get_native_host("http://127.0.0.1:11434") == "http://127.0.0.1:11434"
|
||||||
|
assert adapter._get_chat_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/chat/completions"
|
||||||
|
assert adapter._get_models_url("http://127.0.0.1:11434") == "http://127.0.0.1:11434/v1/models"
|
||||||
|
|
||||||
|
# Base url with /v1
|
||||||
|
assert adapter._get_native_host("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434"
|
||||||
|
assert adapter._get_chat_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/chat/completions"
|
||||||
|
assert adapter._get_models_url("http://192.168.1.81:11434/v1") == "http://192.168.1.81:11434/v1/models"
|
||||||
|
|
||||||
|
# Base url with trailing slash
|
||||||
|
assert adapter._get_native_host("http://192.168.1.81:11434/") == "http://192.168.1.81:11434"
|
||||||
|
assert adapter._get_chat_url("http://192.168.1.81:11434/") == "http://192.168.1.81:11434/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_2_ollama_connection_refused_helpful_message():
|
||||||
|
"""Ollama connection refused error should provide network IP guidance."""
|
||||||
|
# Test connection preflight when endpoint is down
|
||||||
|
res = validate_connection("ollama", token="", base_url="http://127.0.0.1:11434")
|
||||||
|
assert not res["ok"]
|
||||||
|
assert "Не удалось подключиться к http://127.0.0.1:11434 (Connection refused)" in res["message"]
|
||||||
|
assert "http://192.168.1.81:11434" in res["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-3: Antigravity Dynamic Model Discovery Tests ──
|
||||||
|
|
||||||
|
def test_p0_3_antigravity_dynamic_model_discovery():
|
||||||
|
"""ModelDiscoveryService probe should find and query any authenticated Antigravity profile."""
|
||||||
|
discovery = ModelDiscoveryService.get()
|
||||||
|
|
||||||
|
with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status") as mock_st, \
|
||||||
|
patch("antigravity_provider.agy_subprocess.discover_models", return_value={"gemini-2.5-pro": "gemini-2.5-pro"}) as mock_disc:
|
||||||
|
|
||||||
|
# Simulate ag-3 being authenticated
|
||||||
|
def status_side_effect(prov, pid):
|
||||||
|
if pid == "ag-3":
|
||||||
|
return {"authenticated": True, "provider": prov, "profile_id": pid}
|
||||||
|
return {"authenticated": False, "provider": prov, "profile_id": pid}
|
||||||
|
|
||||||
|
mock_st.side_effect = status_side_effect
|
||||||
|
|
||||||
|
models, err = discovery._probe_provider("antigravity")
|
||||||
|
assert err is None
|
||||||
|
assert models == ["gemini-2.5-pro"]
|
||||||
|
mock_disc.assert_called_with(profile_id="ag-3")
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-4: Version Info & API Propagation Tests ──
|
||||||
|
|
||||||
|
def test_p0_4_version_single_source_of_truth():
|
||||||
|
"""Номер версии и VERSION_INFO обязаны совпадать между собой.
|
||||||
|
|
||||||
|
Сверять с записанным в тесте числом бессмысленно: при каждой сборке его
|
||||||
|
пришлось бы править, и тест превращался бы в напоминание, а не в проверку.
|
||||||
|
Смысл требования — единый источник, его и проверяем.
|
||||||
|
"""
|
||||||
|
assert VERSION_INFO == tuple(int(part) for part in __version__.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_4_version_in_api_endpoints():
|
||||||
|
"""Все точки API обязаны отдавать ту же версию, что и пакет."""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# /api/health
|
||||||
|
res_health = client.get("/api/health")
|
||||||
|
assert res_health.status_code == 200
|
||||||
|
assert res_health.json().get("version") == __version__
|
||||||
|
|
||||||
|
# /api/settings
|
||||||
|
res_settings = client.get("/api/settings")
|
||||||
|
assert res_settings.status_code == 200
|
||||||
|
assert res_settings.json().get("version") == __version__
|
||||||
|
|
||||||
|
# /api/snapshot
|
||||||
|
res_snap = client.get("/api/snapshot")
|
||||||
|
assert res_snap.status_code == 200
|
||||||
|
snap = res_snap.json()
|
||||||
|
assert snap.get("version") == __version__
|
||||||
|
assert (snap.get("metrics") or {}).get("version") == __version__
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0-5: Settings View & System Paths Tests ──
|
||||||
|
|
||||||
|
def test_p0_5_settings_api_returns_system_paths_and_hub_settings():
|
||||||
|
"""GET /api/settings must return system_paths, server host/port, token status, and hub settings."""
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get("/api/settings")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
|
||||||
|
# System paths dict
|
||||||
|
assert "system_paths" in data
|
||||||
|
sys_paths = data["system_paths"]
|
||||||
|
assert "hermes_home" in sys_paths
|
||||||
|
assert "config_dir" in sys_paths
|
||||||
|
assert "log_file" in sys_paths
|
||||||
|
|
||||||
|
# Server settings & tokens
|
||||||
|
assert "web_api_host" in data
|
||||||
|
assert "web_api_port" in data
|
||||||
|
assert "web_api_token_configured" in data
|
||||||
|
|
||||||
|
# Snapshot includes system_paths
|
||||||
|
res_snap = client.get("/api/snapshot")
|
||||||
|
assert res_snap.status_code == 200
|
||||||
|
snap = res_snap.json()
|
||||||
|
assert "system_paths" in snap
|
||||||
|
assert snap["system_paths"]["hermes_home"] == sys_paths["hermes_home"]
|
||||||
486
tests/test_a56_context_compression.py
Normal file
486
tests/test_a56_context_compression.py
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
"""Tests for Task A56: Context Compression (Сжатие контекста).
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- P0-1: Compressor is a configurable role/profile, not hardcoded, excluded from Hermes default routing chains.
|
||||||
|
- P0-2: Context threshold triggering (default 75%), fresh window preserved verbatim, no recursive double compression.
|
||||||
|
- P0-3: Strict verbatim preservation of factual entities (file paths, ports, IPs, variable/function names, commit SHAs, versions, metrics) with 100% retention guarantee.
|
||||||
|
- P0-4: Transparent telemetry, history store for original uncompressed text, non-blocking graceful fallback when compressor is offline.
|
||||||
|
- P0-5: Shared memory persistence in /srv/projects/AI-Memory indexed by GGUF build metadata.
|
||||||
|
- P0-6: Audit pass testing on live server (if accessible) and mock offline fallback.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.router.context_compressor import (
|
||||||
|
COMPRESSED_BLOCK_END,
|
||||||
|
COMPRESSED_BLOCK_START,
|
||||||
|
CompressionOutcome,
|
||||||
|
ContextCompressor,
|
||||||
|
FactualEntities,
|
||||||
|
extract_factual_entities,
|
||||||
|
verify_facts_retention,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.local_supervisor import (
|
||||||
|
LocalSupervisor,
|
||||||
|
ServerPropsResult,
|
||||||
|
TokenCountResult,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig
|
||||||
|
from antigravity_provider.router.settings_service import (
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
get_hub_settings,
|
||||||
|
invalidate_settings_cache,
|
||||||
|
save_hub_settings,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Fixtures
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_vault(tmp_path: Path):
|
||||||
|
vault = tmp_path / "AI-Memory"
|
||||||
|
vault.mkdir(parents=True, exist_ok=True)
|
||||||
|
(vault / "01_PROJECTS" / "hermes-hub").mkdir(parents=True, exist_ok=True)
|
||||||
|
return vault
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-1: Configurable role & profile
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_1_compressor_unconfigured_returns_nd_status(tmp_vault: Path):
|
||||||
|
"""When compressor profile is not selected, returns unconfigured N/D without error."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
supervisor = LocalSupervisor(compressor=compressor)
|
||||||
|
|
||||||
|
# Status without profile
|
||||||
|
status = supervisor.get_compression_status(compressor_profile=None)
|
||||||
|
assert status["configured"] is False
|
||||||
|
assert status["status"] == "unconfigured"
|
||||||
|
assert "Н/Д: модель для сжатия не выбрана" in status["display_status"]
|
||||||
|
|
||||||
|
# Compression with unconfigured profile
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "System prompt"},
|
||||||
|
{"role": "user", "content": "Hello world" * 100},
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=1000,
|
||||||
|
current_token_count=800,
|
||||||
|
compressor_profile=None,
|
||||||
|
)
|
||||||
|
assert outcome.status == "UNCONFIGURED"
|
||||||
|
assert "Н/Д" in outcome.status_message
|
||||||
|
assert res_msgs == messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_1_compressor_endpoint_resolved_from_profile(tmp_vault: Path):
|
||||||
|
"""Compressor endpoint is dynamically resolved from profile config, not hardcoded."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="local-compressor-custom",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://192.168.1.100:9999/v1",
|
||||||
|
preferred_models=["CustomCompressor-7B"],
|
||||||
|
auth_config={"api_key": "secret-token-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url, model, token = compressor.resolve_compressor_endpoint(pconfig)
|
||||||
|
assert base_url == "http://192.168.1.100:9999/v1"
|
||||||
|
assert model == "CustomCompressor-7B"
|
||||||
|
assert token == "secret-token-123"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-2: Triggering conditions & threshold
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_2_threshold_skips_when_under_limit(tmp_vault: Path):
|
||||||
|
"""When token count is below threshold percent (e.g. 50% < 75%), compression is skipped."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="compressor-mock",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["Qwen3-4B-2507"],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "System prompt"},
|
||||||
|
{"role": "user", "content": "Message 1"},
|
||||||
|
{"role": "assistant", "content": "Response 1"},
|
||||||
|
{"role": "user", "content": "Message 2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Target 10,000, current 5,000 -> 50% < 75% threshold
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=10000,
|
||||||
|
current_token_count=5000,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=75.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "SKIPPED"
|
||||||
|
assert "В пределах нормы" in outcome.status_message
|
||||||
|
assert res_msgs == messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_2_fresh_window_preserved_verbatim_and_no_double_nesting(tmp_vault: Path):
|
||||||
|
"""Fresh messages remain completely verbatim; existing summary blocks are unnested without recursion."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="compressor-mock",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["Qwen3-4B-2507"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing summary block inside history
|
||||||
|
prev_summary = (
|
||||||
|
f"{COMPRESSED_BLOCK_START}\n"
|
||||||
|
f"## Сжатая сводка предшествующего контекста\n"
|
||||||
|
f"- Server running on 192.168.1.81:8765\n"
|
||||||
|
f"- Active port: :8081\n"
|
||||||
|
f"{COMPRESSED_BLOCK_END}"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "You are a senior developer."},
|
||||||
|
{"role": "user", "content": prev_summary},
|
||||||
|
{"role": "user", "content": "Step 2: Created /srv/projects/hermes/test.py with SHA 8e75dc6."},
|
||||||
|
{"role": "assistant", "content": "Step 2 done."},
|
||||||
|
# Fresh window (last 2 messages)
|
||||||
|
{"role": "user", "content": "Fresh user request: run tests now."},
|
||||||
|
{"role": "assistant", "content": "Fresh assistant response."},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = {
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"- Server running on 192.168.1.81:8765\n"
|
||||||
|
"- Active port :8081\n"
|
||||||
|
"- Created /srv/projects/hermes/test.py with SHA 8e75dc6"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"model": "Qwen3-4B-2507-Instruct-Q4_K_M.gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
|
||||||
|
mock_resp.__enter__.return_value = mock_resp
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=1000,
|
||||||
|
current_token_count=900,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=75.0,
|
||||||
|
keep_recent_messages=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "SUCCESS"
|
||||||
|
assert outcome.retention_percent == 100.0
|
||||||
|
|
||||||
|
# System message preserved
|
||||||
|
assert res_msgs[0]["content"] == "You are a senior developer."
|
||||||
|
|
||||||
|
# Fresh window preserved verbatim
|
||||||
|
assert res_msgs[-2]["content"] == "Fresh user request: run tests now."
|
||||||
|
assert res_msgs[-1]["content"] == "Fresh assistant response."
|
||||||
|
|
||||||
|
# Only 1 single compression block, never nested
|
||||||
|
compressed_content = res_msgs[1]["content"]
|
||||||
|
assert compressed_content.count(COMPRESSED_BLOCK_START) == 1
|
||||||
|
assert compressed_content.count(COMPRESSED_BLOCK_END) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-3: Strict Verbatim Preservation of Factual Entities
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_3_extract_and_verify_all_factual_entities():
|
||||||
|
"""Extracts and verifies 100% of paths, ports, IPs, SHAs, versions, and metrics."""
|
||||||
|
text = (
|
||||||
|
"Server deployed on 192.168.1.81:8765. Primary coder is on port 8081 with 224K context (229376 tokens). "
|
||||||
|
"Measured generation speed: 107.4 tok/s, VRAM: 30008 MiB. Local compressor on port 8082 with 32768 context. "
|
||||||
|
"File: /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py. "
|
||||||
|
"Commit SHA: 26f7d2c, version: v0.1.2. Classes: LocalSupervisor and ContextCompressor."
|
||||||
|
)
|
||||||
|
|
||||||
|
facts = extract_factual_entities(text)
|
||||||
|
assert "/srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py" in facts.file_paths
|
||||||
|
assert "192.168.1.81" in facts.ip_addresses
|
||||||
|
assert any(":8081" in p or "8081" in p for p in facts.port_numbers)
|
||||||
|
assert any(":8082" in p or "8082" in p for p in facts.port_numbers)
|
||||||
|
assert "26f7d2c" in facts.commit_shas
|
||||||
|
assert "v0.1.2" in facts.version_numbers
|
||||||
|
assert any("107.4" in m for m in facts.metrics)
|
||||||
|
assert any("30008" in m for m in facts.metrics)
|
||||||
|
assert "LocalSupervisor" in facts.identifiers
|
||||||
|
|
||||||
|
# Verify retention in exact summary
|
||||||
|
summary = (
|
||||||
|
"Summary:\n"
|
||||||
|
"- 192.168.1.81:8765\n"
|
||||||
|
"- port 8081, 224K, 229376 tokens, 107.4 tok/s, 30008 MiB\n"
|
||||||
|
"- port 8082, 32768 tokens\n"
|
||||||
|
"- /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py\n"
|
||||||
|
"- SHA 26f7d2c, version v0.1.2\n"
|
||||||
|
"- LocalSupervisor, ContextCompressor"
|
||||||
|
)
|
||||||
|
retention, preserved, missing = verify_facts_retention(summary, facts)
|
||||||
|
assert retention >= 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_p0_3_factual_safeguard_ledger_guarantees_100_percent_retention(tmp_vault: Path):
|
||||||
|
"""If LLM summary misses an entity, safeguard ledger automatically appends it to reach 100% retention."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="compressor-mock",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["Qwen3-4B-2507"],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "You are a coder."},
|
||||||
|
{"role": "user", "content": "Critical secret port is :8888 and critical commit is 79ac9cf5610dccf8."},
|
||||||
|
{"role": "assistant", "content": "Understood."},
|
||||||
|
{"role": "user", "content": "Recent question?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model generates a summary that forgot the port and commit
|
||||||
|
mock_llm_response = {
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "User discussed server settings."
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"model": "Qwen3-4B-2507-Instruct-Q4_K_M.gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
|
||||||
|
mock_resp.__enter__.return_value = mock_resp
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=1000,
|
||||||
|
current_token_count=800,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=50.0,
|
||||||
|
keep_recent_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "SUCCESS"
|
||||||
|
assert outcome.retention_percent == 100.0
|
||||||
|
compressed_text = res_msgs[1]["content"]
|
||||||
|
assert ":8888" in compressed_text
|
||||||
|
assert "79ac9cf5610dccf8" in compressed_text
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-4: Non-blocking graceful fallback & history snapshot
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_4_compressor_offline_does_not_crash_task(tmp_vault: Path):
|
||||||
|
"""When compressor server is unreachable, task continues safely on uncompressed context."""
|
||||||
|
compressor = ContextCompressor(memory_file=tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json")
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="compressor-offline",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:9999/v1", # dead port
|
||||||
|
preferred_models=["Qwen3-4B-2507"],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "System message"},
|
||||||
|
{"role": "user", "content": "Original user text" * 20},
|
||||||
|
{"role": "assistant", "content": "Original assistant text" * 20},
|
||||||
|
{"role": "user", "content": "Latest user query"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Simulating connection error
|
||||||
|
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")):
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=100,
|
||||||
|
current_token_count=500,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=50.0,
|
||||||
|
keep_recent_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Task is NOT aborted
|
||||||
|
assert outcome.status == "ERROR"
|
||||||
|
assert "Задача продолжается на исходном контексте" in outcome.status_message
|
||||||
|
assert res_msgs == messages
|
||||||
|
|
||||||
|
# Original messages preserved in history snapshot
|
||||||
|
assert outcome.original_messages_snapshot == messages
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-5: Shared Memory Persistence in AI-Memory
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_5_compression_memory_recorded_by_gguf_name(tmp_vault: Path):
|
||||||
|
"""Records compression history in /srv/projects/AI-Memory indexed by GGUF model."""
|
||||||
|
memory_file = tmp_vault / "01_PROJECTS" / "hermes-hub" / "compression_memory.json"
|
||||||
|
compressor = ContextCompressor(memory_file=memory_file)
|
||||||
|
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="compressor-p1",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["Qwen3-4B-2507"],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "Sys"},
|
||||||
|
{"role": "user", "content": "Server is 192.168.1.81:8081"},
|
||||||
|
{"role": "assistant", "content": "Ok"},
|
||||||
|
{"role": "user", "content": "Next"},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = {
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "- 192.168.1.81:8081 active"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"model": "/srv/ai/models/qwen3-4b-compressor/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.read.return_value = json.dumps(mock_llm_response).encode("utf-8")
|
||||||
|
mock_resp.__enter__.return_value = mock_resp
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||||
|
_, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=100,
|
||||||
|
current_token_count=200,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=50.0,
|
||||||
|
keep_recent_messages=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "SUCCESS"
|
||||||
|
assert memory_file.exists()
|
||||||
|
|
||||||
|
data = json.loads(memory_file.read_text(encoding="utf-8"))
|
||||||
|
assert "compressor_models" in data
|
||||||
|
gguf_key = "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
||||||
|
assert gguf_key in data["compressor_models"]
|
||||||
|
rec = data["compressor_models"][gguf_key]
|
||||||
|
assert rec["successful_compressions"] == 1
|
||||||
|
assert rec["avg_fact_retention_percent"] == 100.0
|
||||||
|
assert len(rec["history"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Web API & Action Handler Integration
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_web_api_compression_endpoints(test_client: TestClient):
|
||||||
|
"""Tests GET /api/compression/status, /api/compression/history, POST /api/compression/test."""
|
||||||
|
# 1. GET /api/compression/status
|
||||||
|
res = test_client.get("/api/compression/status")
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert "threshold_percent" in data
|
||||||
|
assert "display_status" in data
|
||||||
|
|
||||||
|
# 2. GET /api/compression/history
|
||||||
|
res_hist = test_client.get("/api/compression/history")
|
||||||
|
assert res_hist.status_code == 200
|
||||||
|
assert "history" in res_hist.json()
|
||||||
|
|
||||||
|
# 3. POST /api/compression/test
|
||||||
|
res_test = test_client.post("/api/compression/test", json={"profile_id": "none"})
|
||||||
|
assert res_test.status_code == 200
|
||||||
|
test_json = res_test.json()
|
||||||
|
assert "message" in test_json
|
||||||
|
assert "data" in test_json
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# P0-6: Live Server Verification (if 8082 is reachable)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
def test_p0_6_live_compressor_verification():
|
||||||
|
"""Live verification against local llama-server compressor on port 8082 if online."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request("http://127.0.0.1:8082/health", headers={"User-Agent": "Test/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=3.0) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if data.get("status") != "ok":
|
||||||
|
pytest.skip("Local compressor server on 8082 not healthy")
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.skip(f"Local compressor server on 8082 not reachable: {exc}")
|
||||||
|
|
||||||
|
compressor = ContextCompressor()
|
||||||
|
pconfig = RouterProfileConfig(
|
||||||
|
profile_id="live-compressor",
|
||||||
|
provider="local",
|
||||||
|
custom_base_url="http://127.0.0.1:8082/v1",
|
||||||
|
preferred_models=["default"],
|
||||||
|
)
|
||||||
|
|
||||||
|
test_messages = [
|
||||||
|
{"role": "system", "content": "You are a software engineer."},
|
||||||
|
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. Primary coder is on port 8081 with 224K context (229376 tokens), speed 107.4 tok/s, VRAM 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082 on CPU."},
|
||||||
|
{"role": "assistant", "content": "Server metrics and ports registered."},
|
||||||
|
{"role": "user", "content": "File path is /srv/projects/Agent projects/hermes-hub/src/antigravity_provider/router/local_supervisor.py."},
|
||||||
|
{"role": "assistant", "content": "Path registered."},
|
||||||
|
{"role": "user", "content": "What is our next action?"},
|
||||||
|
]
|
||||||
|
|
||||||
|
res_msgs, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=test_messages,
|
||||||
|
target_context_limit=32768,
|
||||||
|
current_token_count=150,
|
||||||
|
compressor_profile=pconfig,
|
||||||
|
threshold_percent=0.0, # force compression
|
||||||
|
keep_recent_messages=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "SUCCESS"
|
||||||
|
assert outcome.retention_percent == 100.0
|
||||||
|
assert outcome.saved_tokens >= 0
|
||||||
|
assert len(res_msgs) == 4 # system + compressed + 2 fresh
|
||||||
|
|
||||||
|
compressed_body = res_msgs[1]["content"]
|
||||||
|
assert "192.168.1.81" in compressed_body
|
||||||
|
assert "8081" in compressed_body
|
||||||
|
assert "8082" in compressed_body
|
||||||
|
assert "26f7d2c" in compressed_body
|
||||||
|
assert "v0.1.2" in compressed_body
|
||||||
233
tests/test_a56_review_findings.py
Normal file
233
tests/test_a56_review_findings.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""Проверки по итогам разбора A56 ревьюером.
|
||||||
|
|
||||||
|
Три находки, каждая подтверждена измерением на сервере владельца:
|
||||||
|
|
||||||
|
1. `/props` и `/tokenize` у llama.cpp живут в корне, а не под `/v1`.
|
||||||
|
Измерено: `/props` → 200, `/v1/props` → 404; то же с `/tokenize`.
|
||||||
|
Адаптер передаёт супервизору адрес вида `.../v1`, поэтому счёт токенов
|
||||||
|
молча падал на посимвольную оценку, и порог сжатия считался от выдуманного
|
||||||
|
числа.
|
||||||
|
|
||||||
|
2. Заявленные «100% сохранения фактов» модель не даёт. Измерено на живом
|
||||||
|
компрессоре: 5667 токенов на входе, 624 на выходе, 37 фактов из 38 —
|
||||||
|
97,4%, потерян `001cd1f`. Сто процентов получаются дописыванием
|
||||||
|
недостающих фактов списком. Механизм верный, но подменять им измеренное
|
||||||
|
число нельзя: иначе ухудшение модели останется незамеченным.
|
||||||
|
|
||||||
|
3. Порт 8082 был зашит в коде как запасной адрес. Задание это прямо
|
||||||
|
запрещает: сегодня он такой, завтра другой.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.router.context_compressor import (
|
||||||
|
CompressionOutcome,
|
||||||
|
ContextCompressor,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||||
|
|
||||||
|
|
||||||
|
# ── Находка 1: адрес /props и /tokenize ──
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"given, expected_root",
|
||||||
|
[
|
||||||
|
("http://127.0.0.1:8081/v1", "http://127.0.0.1:8081"),
|
||||||
|
("http://127.0.0.1:8081/v1/", "http://127.0.0.1:8081"),
|
||||||
|
("http://127.0.0.1:8081", "http://127.0.0.1:8081"),
|
||||||
|
("http://server.local:9000/v1", "http://server.local:9000"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_props_and_tokenize_go_to_the_root(given, expected_root):
|
||||||
|
assert LocalSupervisor(base_url=given).root_url == expected_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_tokenize_request_url_has_no_v1():
|
||||||
|
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _fake_urlopen(req, timeout=None):
|
||||||
|
seen["url"] = req.full_url
|
||||||
|
raise RuntimeError("сеть в тесте недоступна")
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", _fake_urlopen):
|
||||||
|
supervisor.count_tokens("проверка")
|
||||||
|
|
||||||
|
assert seen["url"] == "http://127.0.0.1:8081/tokenize"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_props_request_url_has_no_v1():
|
||||||
|
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _fake_urlopen(req, timeout=None):
|
||||||
|
seen["url"] = req.full_url
|
||||||
|
raise RuntimeError("сеть в тесте недоступна")
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", _fake_urlopen):
|
||||||
|
supervisor.query_server_props()
|
||||||
|
|
||||||
|
assert seen["url"] == "http://127.0.0.1:8081/props"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_unmeasured_limit_is_marked_as_such():
|
||||||
|
"""Недоступный /props даёт запасное значение — и оно обязано быть помечено."""
|
||||||
|
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
|
||||||
|
|
||||||
|
def _fake_urlopen(req, timeout=None):
|
||||||
|
raise RuntimeError("сервер не отвечает")
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", _fake_urlopen):
|
||||||
|
result = supervisor.query_server_props()
|
||||||
|
|
||||||
|
assert result.is_measured is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Находка 2: полнота фактов не подменяется исправленной ──
|
||||||
|
|
||||||
|
class _Profile:
|
||||||
|
custom_base_url = "http://127.0.0.1:9999/v1"
|
||||||
|
preferred_models = ["compressor"]
|
||||||
|
auth_config: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_with_summary(summary: str):
|
||||||
|
payload = {"choices": [{"message": {"content": summary}}], "model": "test-gguf"}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def read(self):
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(payload).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "система"},
|
||||||
|
{"role": "user", "content": "Правка в коммите 001cd1f, порт 8082, файл /srv/app/main.py"},
|
||||||
|
{"role": "assistant", "content": "Скорость 107.4 tok/s на версии v0.1.3"},
|
||||||
|
{"role": "user", "content": "свежее 1"},
|
||||||
|
{"role": "user", "content": "свежее 2"},
|
||||||
|
{"role": "user", "content": "свежее 3"},
|
||||||
|
]
|
||||||
|
compressor = ContextCompressor()
|
||||||
|
with patch("urllib.request.urlopen", return_value=_Resp()), \
|
||||||
|
patch.object(ContextCompressor, "_record_to_shared_memory", lambda self, o: None):
|
||||||
|
_, outcome = compressor.compress_messages_if_needed(
|
||||||
|
messages=messages,
|
||||||
|
target_context_limit=1000,
|
||||||
|
current_token_count=900,
|
||||||
|
compressor_profile=_Profile(),
|
||||||
|
)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_model_retention_is_reported_separately_when_facts_were_added():
|
||||||
|
# Сводка намеренно теряет часть фактов — их дописывает страховка.
|
||||||
|
outcome = _compress_with_summary("Кратко: правка внесена, порт 8082.")
|
||||||
|
|
||||||
|
assert outcome.status == "SUCCESS"
|
||||||
|
assert outcome.facts_added_by_safeguard, "страховка обязана была сработать"
|
||||||
|
assert outcome.model_retention_percent < 100.0
|
||||||
|
assert outcome.retention_percent == pytest.approx(100.0)
|
||||||
|
assert "дописано списком" in outcome.status_message
|
||||||
|
assert f"{outcome.model_retention_percent:.1f}%" in outcome.status_message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_full_model_retention_is_stated_as_the_model_s_own():
|
||||||
|
summary = "Коммит 001cd1f, порт 8082, файл /srv/app/main.py, 107.4 tok/s, v0.1.3"
|
||||||
|
outcome = _compress_with_summary(summary)
|
||||||
|
|
||||||
|
assert not outcome.facts_added_by_safeguard
|
||||||
|
assert outcome.model_retention_percent == pytest.approx(100.0)
|
||||||
|
assert "самой моделью" in outcome.status_message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_status_message_never_hardcodes_a_hundred_percent():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from antigravity_provider.router import context_compressor
|
||||||
|
|
||||||
|
source = inspect.getsource(context_compressor)
|
||||||
|
assert "(100%)." not in source, "полнота обязана браться из замера, а не из строки"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Находка 3: порт компрессора не зашит ──
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_compressor_port_is_not_hardcoded():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from antigravity_provider.router import context_compressor
|
||||||
|
|
||||||
|
code_lines = [
|
||||||
|
line
|
||||||
|
for line in inspect.getsource(context_compressor).splitlines()
|
||||||
|
if not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
assert not any("127.0.0.1:8082" in line for line in code_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_profile_without_address_is_unconfigured_not_port_8082(monkeypatch):
|
||||||
|
monkeypatch.delenv("LOCAL_COMPRESSOR_BASE_URL", raising=False)
|
||||||
|
|
||||||
|
class _NoAddress:
|
||||||
|
custom_base_url = None
|
||||||
|
preferred_models: list = []
|
||||||
|
auth_config: dict = {}
|
||||||
|
|
||||||
|
_, outcome = ContextCompressor().compress_messages_if_needed(
|
||||||
|
messages=[{"role": "user", "content": "x"}],
|
||||||
|
target_context_limit=1000,
|
||||||
|
current_token_count=900,
|
||||||
|
compressor_profile=_NoAddress(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.status == "UNCONFIGURED"
|
||||||
|
assert "адрес" in outcome.status_message
|
||||||
|
|
||||||
|
|
||||||
|
# ── Итог сжатия пересчитывается токенизатором ──
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_result_size_is_recounted_by_the_tokenizer():
|
||||||
|
supervisor = LocalSupervisor(base_url="http://127.0.0.1:8081/v1")
|
||||||
|
outcome = CompressionOutcome(
|
||||||
|
status="SUCCESS", status_message="", tokens_before=900, tokens_after=1
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ContextCompressor,
|
||||||
|
"compress_messages_if_needed",
|
||||||
|
return_value=([{"role": "user", "content": "сводка"}], outcome),
|
||||||
|
), patch.object(
|
||||||
|
LocalSupervisor,
|
||||||
|
"count_tokens",
|
||||||
|
side_effect=[
|
||||||
|
type("T", (), {"tokens_count": 900, "is_estimated": False})(),
|
||||||
|
type("T", (), {"tokens_count": 120, "is_estimated": False})(),
|
||||||
|
],
|
||||||
|
):
|
||||||
|
_, result = supervisor.compress_context_if_needed(
|
||||||
|
messages=[{"role": "user", "content": "длинная история"}],
|
||||||
|
target_context_limit=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.tokens_after == 120
|
||||||
|
assert result.tokens_after_is_estimate is False
|
||||||
|
assert result.saved_tokens == 780
|
||||||
402
tests/test_a57_agy_native_login.py
Normal file
402
tests/test_a57_agy_native_login.py
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
"""Tests for Task A57: Antigravity Native Login via agy CLI.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- P0-1: Native agy CLI invocation in terminal with isolated HOME/USERPROFILE/HOMEPATH.
|
||||||
|
Terminal lookup on Linux and Windows with honest error reporting when missing.
|
||||||
|
Waiting for credentials by file detection rather than process exit code.
|
||||||
|
- P0-2: Profile slot is pre-selected and isolated. Occupied slots require confirmation.
|
||||||
|
Existing account directories in ~/.hermes/agy_profiles/ are never touched.
|
||||||
|
- P0-3: Account identity is truthfully read from agy-generated files without inventing emails.
|
||||||
|
AutoAssigner.check_duplicate_identity de-duplicates accounts and prevents slot creep.
|
||||||
|
- P0-4: Browser OAuth redirect path is preserved as fallback.
|
||||||
|
- P0-5: Failures report exact cause (including stderr, checked candidate terminals, and HOME).
|
||||||
|
- P0-6: Security: no credentials in logs, directory permissions 0700, file permissions 0600.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from antigravity_provider import agy_subprocess
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.agy_subprocess import (
|
||||||
|
check_profile_native_auth_status,
|
||||||
|
find_terminal_emulator,
|
||||||
|
poll_native_agy_login,
|
||||||
|
start_native_agy_login,
|
||||||
|
)
|
||||||
|
from antigravity_provider.paths import get_profile_dir
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
from antigravity_provider.router.router_config import (
|
||||||
|
RouterConfig,
|
||||||
|
RouterProfileConfig,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_hermes_env(tmp_path, monkeypatch):
|
||||||
|
"""Ensure every test runs with fully isolated HERMES_HOME and temporary environment."""
|
||||||
|
h_home = tmp_path / "hermes_home"
|
||||||
|
h_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(h_home))
|
||||||
|
monkeypatch.setenv("DISPLAY", ":10.0")
|
||||||
|
|
||||||
|
# Initial empty router config
|
||||||
|
cfg = RouterConfig()
|
||||||
|
save_router_config(cfg)
|
||||||
|
return h_home
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 1: Terminal discovery on Linux ──
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
|
||||||
|
"""P0-1: find_terminal_emulator finds available terminal on Linux and returns correct command."""
|
||||||
|
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
|
||||||
|
monkeypatch.setenv("DISPLAY", ":10.0")
|
||||||
|
|
||||||
|
def mock_which(cmd):
|
||||||
|
if cmd == "gnome-terminal":
|
||||||
|
return "/usr/bin/gnome-terminal"
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", mock_which)
|
||||||
|
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
|
||||||
|
assert err is None
|
||||||
|
assert cmd is not None
|
||||||
|
assert cmd[0] == "/usr/bin/gnome-terminal"
|
||||||
|
assert "--" in cmd
|
||||||
|
# Поиск терминала подставляет то, что ему передали, и ничего не создаёт.
|
||||||
|
# Сценарий входа готовит start_native_agy_login.
|
||||||
|
assert "/bin/agy" in cmd
|
||||||
|
assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch):
|
||||||
|
"""P0-1: When no terminal emulator exists on Linux, report honest error listing checked candidates."""
|
||||||
|
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
|
||||||
|
monkeypatch.setenv("DISPLAY", ":10.0")
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda cmd: None)
|
||||||
|
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
|
||||||
|
assert cmd is None
|
||||||
|
assert err is not None
|
||||||
|
assert "Терминал не найден на сервере" in err
|
||||||
|
assert "x-terminal-emulator" in err
|
||||||
|
assert "gnome-terminal" in err
|
||||||
|
assert "konsole" in err
|
||||||
|
assert "xterm" in err
|
||||||
|
assert len(checked) >= 8
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch):
|
||||||
|
"""P0-1: сеанса нет ни в окружении, ни у systemd — отказ с перечнем проверенного.
|
||||||
|
|
||||||
|
Пустое окружение само по себе больше не приговор: хаб запускается через
|
||||||
|
nohup и по SSH остаётся без DISPLAY, стоя при этом на рабочем столе. Отказ
|
||||||
|
правомерен, только когда и systemd графического сеанса не знает.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
|
||||||
|
monkeypatch.delenv("DISPLAY", raising=False)
|
||||||
|
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||||
|
monkeypatch.delenv("MIR_SOCKET", raising=False)
|
||||||
|
monkeypatch.setattr(agy_subprocess.shutil, "which", lambda _n: None)
|
||||||
|
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
|
||||||
|
assert cmd is None
|
||||||
|
assert err is not None
|
||||||
|
assert "Графический сеанс не обнаружен" in err
|
||||||
|
assert any("DISPLAY (не задан)" in item for item in checked)
|
||||||
|
assert any("loginctl" in item for item in checked)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_terminal_discovery_windows(tmp_path, monkeypatch):
|
||||||
|
"""P0-1: On Windows, find_terminal_emulator selects wt.exe or cmd.exe."""
|
||||||
|
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda c: "C:\\Windows\\System32\\wt.exe" if "wt" in c else None)
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
|
||||||
|
assert err is None
|
||||||
|
assert cmd is not None
|
||||||
|
assert "wt.exe" in cmd[0]
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda c: None)
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
|
||||||
|
assert err is None
|
||||||
|
assert cmd is not None
|
||||||
|
assert "cmd.exe" in cmd[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 2: Start native agy login & slot protection ──
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_start_native_agy_login_creates_isolated_home(tmp_path, monkeypatch):
|
||||||
|
"""P0-1/P0-2: start_native_agy_login sets up profile directory with 0700 permissions and launches terminal."""
|
||||||
|
slot = "ag-1"
|
||||||
|
pdir = get_profile_dir(slot, "antigravity")
|
||||||
|
|
||||||
|
mock_popen = MagicMock()
|
||||||
|
monkeypatch.setattr("subprocess.Popen", mock_popen)
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id=slot)
|
||||||
|
assert ok is True
|
||||||
|
assert "Терминал успешно запущен" in msg
|
||||||
|
assert data["profile_id"] == "ag-1"
|
||||||
|
assert "session_id" in data
|
||||||
|
assert pdir.is_dir()
|
||||||
|
|
||||||
|
# Изоляция обеспечивается сценарием входа, а не окружением терминала.
|
||||||
|
# HOME терминалу подменять нельзя: клиенты X11 берут ключ авторизации из
|
||||||
|
# ~/.Xauthority, и с подменённым HOME его там нет — xfce4-terminal выходил
|
||||||
|
# с кодом 1, не открыв окна. Сценарий задаёт HOME сам, уже внутри окна.
|
||||||
|
mock_popen.assert_called_once()
|
||||||
|
call_kwargs = mock_popen.call_args[1]
|
||||||
|
assert call_kwargs["env"].get("HOME") != str(pdir)
|
||||||
|
assert call_kwargs["cwd"] == str(pdir)
|
||||||
|
|
||||||
|
helper = pdir / ".hermes-agy-login.sh"
|
||||||
|
assert helper.is_file(), "сценарий входа должен быть создан"
|
||||||
|
body = helper.read_text(encoding="utf-8")
|
||||||
|
assert str(pdir) in body and "export HOME" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_start_native_agy_login_occupied_slot_requires_confirmation(tmp_path, monkeypatch):
|
||||||
|
"""P0-2: Attempting to log into an already authenticated slot requires confirmation unless force=True."""
|
||||||
|
slot = "ag-2"
|
||||||
|
pdir = get_profile_dir(slot, "antigravity")
|
||||||
|
pdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
auth_file = pdir / "auth.json"
|
||||||
|
auth_file.write_text(json.dumps({
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"email": "existing.developer@gmail.com",
|
||||||
|
"token": {"access_token": "ya29.active_token", "refresh_token": "1//active_ref"},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
# First attempt without force -> confirmation required
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id=slot, force=False)
|
||||||
|
assert ok is False
|
||||||
|
assert data.get("confirmation_required") is True
|
||||||
|
assert "уже занят аккаунтом" in msg
|
||||||
|
|
||||||
|
# Second attempt with force=True -> proceeds
|
||||||
|
mock_popen = MagicMock()
|
||||||
|
monkeypatch.setattr("subprocess.Popen", mock_popen)
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id=slot, force=True)
|
||||||
|
assert ok is True
|
||||||
|
assert "session_id" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 3: Polling detection of credentials written by agy ──
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_poll_native_agy_login_detects_antigravity_oauth_token(tmp_path, monkeypatch):
|
||||||
|
"""P0-1/P0-3: Poller detects .gemini/antigravity-cli/antigravity-oauth-token written by agy."""
|
||||||
|
slot = "ag-3"
|
||||||
|
pdir = get_profile_dir(slot, "antigravity")
|
||||||
|
|
||||||
|
monkeypatch.setattr("subprocess.Popen", MagicMock())
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id=slot)
|
||||||
|
assert ok is True
|
||||||
|
session_id = data["session_id"]
|
||||||
|
|
||||||
|
# While file is not written yet -> pending
|
||||||
|
ok, msg, p_data = poll_native_agy_login(session_id)
|
||||||
|
assert ok is True
|
||||||
|
assert p_data["status"] == "pending"
|
||||||
|
|
||||||
|
# Simulate agy CLI writing its token file
|
||||||
|
cli_dir = pdir / ".gemini" / "antigravity-cli"
|
||||||
|
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_file = cli_dir / "antigravity-oauth-token"
|
||||||
|
token_file.write_text(json.dumps({
|
||||||
|
"auth_method": "consumer",
|
||||||
|
"token": {
|
||||||
|
"access_token": "ya29.native_agy_token",
|
||||||
|
"refresh_token": "1//native_agy_refresh",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expiry": "2026-09-01T16:00:00Z",
|
||||||
|
},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
# Simulate active account identification
|
||||||
|
acc_file = pdir / ".gemini" / "google_accounts.json"
|
||||||
|
acc_file.write_text(json.dumps({"active": "agy.native@gmail.com"}), encoding="utf-8")
|
||||||
|
|
||||||
|
# Poller should detect completion
|
||||||
|
ok, msg, p_data = poll_native_agy_login(session_id)
|
||||||
|
assert ok is True
|
||||||
|
assert p_data["status"] == "completed"
|
||||||
|
assert p_data["email"] == "agy.native@gmail.com"
|
||||||
|
assert p_data["profile_id"] == "ag-3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_poll_native_agy_login_truthful_email_or_na(tmp_path, monkeypatch):
|
||||||
|
"""P0-3: When email identity cannot be found, display 'Н/Д', never an invented name."""
|
||||||
|
slot = "ag-4"
|
||||||
|
pdir = get_profile_dir(slot, "antigravity")
|
||||||
|
|
||||||
|
monkeypatch.setattr("subprocess.Popen", MagicMock())
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id=slot)
|
||||||
|
session_id = data["session_id"]
|
||||||
|
|
||||||
|
cli_dir = pdir / ".gemini" / "antigravity-cli"
|
||||||
|
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_file = cli_dir / "antigravity-oauth-token"
|
||||||
|
token_file.write_text(json.dumps({
|
||||||
|
"auth_method": "consumer",
|
||||||
|
"token": {
|
||||||
|
"access_token": "ya29.anon_token",
|
||||||
|
"refresh_token": "1//anon_refresh",
|
||||||
|
},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
ok, msg, p_data = poll_native_agy_login(session_id)
|
||||||
|
assert ok is True
|
||||||
|
assert p_data["status"] == "completed"
|
||||||
|
assert "Н/Д" in p_data["email"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_poll_native_agy_login_duplicate_identity_deduplication(tmp_path, monkeypatch):
|
||||||
|
"""P0-3: When logging into a new slot with an account already registered in another slot, redirect to existing slot."""
|
||||||
|
# Slot 1 already has account
|
||||||
|
s1_dir = get_profile_dir("ag-1", "antigravity")
|
||||||
|
s1_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(s1_dir / "auth.json").write_text(json.dumps({
|
||||||
|
"email": "corp.developer@company.com",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
"token": {"access_token": "ya29.old", "refresh_token": "1//old"},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = RouterConfig()
|
||||||
|
cfg.profiles["ag-1"] = RouterProfileConfig(profile_id="ag-1", provider="antigravity")
|
||||||
|
save_router_config(cfg)
|
||||||
|
|
||||||
|
# User attempts login into ag-5
|
||||||
|
s5_dir = get_profile_dir("ag-5", "antigravity")
|
||||||
|
monkeypatch.setattr("subprocess.Popen", MagicMock())
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, data = start_native_agy_login(profile_id="ag-5")
|
||||||
|
session_id = data["session_id"]
|
||||||
|
|
||||||
|
# agy writes credentials for corp.developer@company.com into ag-5
|
||||||
|
cli_dir = s5_dir / ".gemini" / "antigravity-cli"
|
||||||
|
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(cli_dir / "antigravity-oauth-token").write_text(json.dumps({
|
||||||
|
"auth_method": "consumer",
|
||||||
|
"token": {"access_token": "ya29.new_corp", "refresh_token": "1//new_corp"},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(s5_dir / ".gemini" / "google_accounts.json").write_text(
|
||||||
|
json.dumps({"active": "corp.developer@company.com"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
ok, msg, p_data = poll_native_agy_login(session_id)
|
||||||
|
assert ok is True
|
||||||
|
assert p_data["status"] == "completed"
|
||||||
|
# De-duplicated back to ag-1
|
||||||
|
assert p_data["profile_id"] == "ag-1"
|
||||||
|
assert p_data["email"] == "corp.developer@company.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ── TEST 4: Action executor integration & browser fallback ──
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_action_handler_native_auth_routes(monkeypatch):
|
||||||
|
"""P0-1/P0-5: ActionExecutor handles start_native_auth and poll_native_auth."""
|
||||||
|
monkeypatch.setattr("subprocess.Popen", MagicMock())
|
||||||
|
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"antigravity_provider.agy_subprocess.find_terminal_emulator",
|
||||||
|
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = ActionExecutor.execute("start_native_auth", {"provider": "antigravity", "profile_id": "ag-6"})
|
||||||
|
assert res["ok"] is True
|
||||||
|
session_id = res["data"]["session_id"]
|
||||||
|
|
||||||
|
poll_res = ActionExecutor.execute("poll_native_auth", {"session_id": session_id})
|
||||||
|
assert poll_res["ok"] is True
|
||||||
|
assert poll_res["data"]["status"] == "pending"
|
||||||
|
|
||||||
|
cancel_res = ActionExecutor.execute("cancel_native_auth", {"session_id": session_id})
|
||||||
|
assert cancel_res["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_browser_redirect_auth_fallback_preserved():
|
||||||
|
"""P0-4: Existing browser OAuth redirect path is fully functional as fallback."""
|
||||||
|
res = ActionExecutor.execute("start_redirect_auth", {"provider": "antigravity", "profile_id": "ag-7"})
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert "url" in res["data"]
|
||||||
|
assert "session_id" in res["data"]
|
||||||
|
assert res["data"]["paste_kind"] == "url"
|
||||||
|
|
||||||
|
ActionExecutor.execute("cancel_redirect_auth", {"session_id": res["data"]["session_id"]})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_permissions_0700_and_0600(tmp_path):
|
||||||
|
"""P0-6: Profile directories are created with 0700 and credential files with 0600."""
|
||||||
|
slot = "ag-8"
|
||||||
|
pdir = get_profile_dir(slot, "antigravity")
|
||||||
|
pdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(pdir, 0o700)
|
||||||
|
|
||||||
|
cli_dir = pdir / ".gemini" / "antigravity-cli"
|
||||||
|
cli_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
token_file = cli_dir / "antigravity-oauth-token"
|
||||||
|
token_file.write_text(json.dumps({
|
||||||
|
"auth_method": "consumer",
|
||||||
|
"token": {"access_token": "ya29.perm_test", "refresh_token": "1//perm"},
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
check_profile_native_auth_status(slot)
|
||||||
|
|
||||||
|
# Check directory permissions (on POSIX systems)
|
||||||
|
if os.name != "nt":
|
||||||
|
assert oct(pdir.stat().st_mode & 0o777) == "0o700"
|
||||||
|
assert oct(token_file.stat().st_mode & 0o777) == "0o600"
|
||||||
248
tests/test_a57_review_terminal_launch.py
Normal file
248
tests/test_a57_review_terminal_launch.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""Проверки по итогам разбора A57 ревьюером.
|
||||||
|
|
||||||
|
Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
|
||||||
|
ag-6», а окна не появлялось. На сервере владельца в это время висел
|
||||||
|
`[xfce4-terminal] <defunct>` — терминал стартовал и немедленно умирал.
|
||||||
|
|
||||||
|
Причин три, и каждая проверяется здесь.
|
||||||
|
|
||||||
|
1. `x-terminal-emulator` на Ubuntu указывает на `xfce4-terminal.wrapper`, а
|
||||||
|
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание
|
||||||
|
работающему экземпляру и завершается. Нужен `--disable-server`.
|
||||||
|
2. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
|
||||||
|
подменённый HOME не применяется — вход ушёл бы в настоящий домашний
|
||||||
|
каталог владельца мимо всей изоляции слотов. Поэтому окружение задаёт сам
|
||||||
|
сценарий, а не наследование.
|
||||||
|
3. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
|
||||||
|
нельзя. Сценарий ждёт нажатия клавиши.
|
||||||
|
|
||||||
|
И отдельно: возврат Popen ничего не говорит об открытии окна.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider import agy_subprocess
|
||||||
|
from antigravity_provider.agy_subprocess import (
|
||||||
|
find_terminal_emulator,
|
||||||
|
write_login_helper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def linux(monkeypatch):
|
||||||
|
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
|
||||||
|
monkeypatch.setenv("DISPLAY", ":0")
|
||||||
|
|
||||||
|
|
||||||
|
def _only(available: set[str]):
|
||||||
|
def _which(name):
|
||||||
|
return f"/usr/bin/{name}" if name in available else None
|
||||||
|
|
||||||
|
return _which
|
||||||
|
|
||||||
|
|
||||||
|
# ── Сценарий входа ──
|
||||||
|
|
||||||
|
def test_helper_sets_environment_itself(tmp_path):
|
||||||
|
helper = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6")
|
||||||
|
body = helper.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "HOME=" in body and str(tmp_path) in body
|
||||||
|
assert "export HOME" in body
|
||||||
|
assert "/usr/local/bin/agy" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_keeps_the_window_open(tmp_path):
|
||||||
|
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "read " in body, "иначе окно исчезнет вместе с agy и причина отказа пропадёт"
|
||||||
|
assert "$status" in body, "код возврата agy должен быть виден владельцу"
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_contains_no_secrets(tmp_path):
|
||||||
|
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8").lower()
|
||||||
|
|
||||||
|
for forbidden in ("access_token", "refresh_token", "api_key", "bearer", "ya29."):
|
||||||
|
assert forbidden not in body
|
||||||
|
|
||||||
|
|
||||||
|
# ── Выбор эмулятора ──
|
||||||
|
|
||||||
|
def test_xfce4_terminal_gets_disable_server(linux, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(shutil, "which", _only({"xfce4-terminal", "x-terminal-emulator"}))
|
||||||
|
|
||||||
|
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert cmd[0] == "/usr/bin/xfce4-terminal"
|
||||||
|
assert "--disable-server" in cmd, (
|
||||||
|
"без этого вызов уходит работающему экземпляру и окно не открывается"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path):
|
||||||
|
"""x-terminal-emulator — обёртка над альтернативами, лишний слой."""
|
||||||
|
monkeypatch.setattr(shutil, "which", _only({"x-terminal-emulator", "xterm"}))
|
||||||
|
|
||||||
|
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert cmd[0] == "/usr/bin/xterm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_discovery_creates_nothing(linux, monkeypatch, tmp_path):
|
||||||
|
"""Поиск терминала — запрос, а не действие: файлов после него не остаётся."""
|
||||||
|
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
|
||||||
|
|
||||||
|
cmd, err, _ = find_terminal_emulator("ag-6", "/путь/к/запуску", tmp_path)
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
assert "/путь/к/запуску" in cmd
|
||||||
|
assert not (tmp_path / ".hermes-agy-login.sh").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(shutil, "which", _only(set()))
|
||||||
|
|
||||||
|
cmd, err, checked = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||||||
|
|
||||||
|
assert cmd is None
|
||||||
|
assert err
|
||||||
|
assert len(checked) >= 5
|
||||||
|
assert any("xterm" in item for item in checked)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Запуск подтверждается, а не объявляется ──
|
||||||
|
|
||||||
|
def test_immediate_exit_is_reported_as_failure(linux, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
|
||||||
|
monkeypatch.setattr(agy_subprocess.time, "sleep", lambda _s: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agy_subprocess, "get_agy_exe", lambda: "/usr/local/bin/agy", raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class _DeadProcess:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", _DeadProcess)
|
||||||
|
|
||||||
|
ok, msg, data = agy_subprocess.start_native_agy_login(profile_id="ag-6", force=True)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert "окно не открылось" in msg
|
||||||
|
assert data.get("exit_code") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Пустые слоты появлялись сами ──
|
||||||
|
|
||||||
|
def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch):
|
||||||
|
"""У владельца накопилось 25 каталогов профилей при единицах заведённых.
|
||||||
|
|
||||||
|
Причина: get_profile_dir создавал каталог при каждом обращении, а в коде
|
||||||
|
зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback,
|
||||||
|
ag-1..ag-20, ag-w1..ag-w10. Любой обход этого списка материализовал их все.
|
||||||
|
Спросить, где профиль жил бы, и завести его — разные действия.
|
||||||
|
"""
|
||||||
|
from antigravity_provider import paths
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
root = tmp_path / "agy_profiles"
|
||||||
|
|
||||||
|
for pid in ("ag-orch-primary", "ag-w7", "ag-19", "ag-cold-3"):
|
||||||
|
paths.get_profile_dir(pid, "antigravity")
|
||||||
|
|
||||||
|
assert not root.exists() or not any(root.iterdir()), (
|
||||||
|
"запрос пути не должен оставлять каталоги на диске"
|
||||||
|
)
|
||||||
|
|
||||||
|
created = paths.get_profile_dir("ag-1", "antigravity", create=True)
|
||||||
|
assert created.is_dir()
|
||||||
|
assert sorted(p.name for p in root.iterdir()) == ["ag-1"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Дисплей ищется не только в окружении ──
|
||||||
|
|
||||||
|
def test_display_from_environment_wins(monkeypatch):
|
||||||
|
from antigravity_provider.agy_subprocess import detect_graphical_session
|
||||||
|
|
||||||
|
monkeypatch.setenv("DISPLAY", ":0")
|
||||||
|
found, checked = detect_graphical_session()
|
||||||
|
|
||||||
|
assert found["DISPLAY"] == ":0"
|
||||||
|
assert any("DISPLAY (задан: :0)" in item for item in checked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_is_asked_from_systemd_when_environment_is_empty(monkeypatch):
|
||||||
|
"""Хаб запускается через nohup: по SSH он остаётся без DISPLAY.
|
||||||
|
|
||||||
|
Измерено на сервере владельца: loginctl show-user даёт сеанс c1, а
|
||||||
|
show-session c1 — Type=x11, Display=:10. Отказываться, не спросив, рано.
|
||||||
|
"""
|
||||||
|
from antigravity_provider import agy_subprocess as m
|
||||||
|
|
||||||
|
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
monkeypatch.setenv("USER", "ochenstarik")
|
||||||
|
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
|
||||||
|
|
||||||
|
answers = {("show-user", "ochenstarik"): "c1", ("show-session", "c1", "Type"): "x11",
|
||||||
|
("show-session", "c1", "Display"): ":10"}
|
||||||
|
|
||||||
|
class _Res:
|
||||||
|
def __init__(self, out):
|
||||||
|
self.stdout, self.returncode = out, 0
|
||||||
|
|
||||||
|
def _run(cmd, **kwargs):
|
||||||
|
if cmd[1] == "show-user":
|
||||||
|
return _Res(answers[("show-user", cmd[2])])
|
||||||
|
return _Res(answers[("show-session", cmd[2], cmd[-1])])
|
||||||
|
|
||||||
|
monkeypatch.setattr(m.subprocess, "run", _run)
|
||||||
|
found, checked = m.detect_graphical_session()
|
||||||
|
|
||||||
|
assert found == {"DISPLAY": ":10"}
|
||||||
|
assert any("loginctl сеанс c1" in item for item in checked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wayland_session_is_reported_as_wayland(monkeypatch):
|
||||||
|
from antigravity_provider import agy_subprocess as m
|
||||||
|
|
||||||
|
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
monkeypatch.setenv("USER", "owner")
|
||||||
|
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
|
||||||
|
|
||||||
|
class _Res:
|
||||||
|
def __init__(self, out):
|
||||||
|
self.stdout, self.returncode = out, 0
|
||||||
|
|
||||||
|
def _run(cmd, **kwargs):
|
||||||
|
if cmd[1] == "show-user":
|
||||||
|
return _Res("2")
|
||||||
|
return _Res("wayland" if cmd[-1] == "Type" else "wayland-0")
|
||||||
|
|
||||||
|
monkeypatch.setattr(m.subprocess, "run", _run)
|
||||||
|
found, _ = m.detect_graphical_session()
|
||||||
|
|
||||||
|
assert found == {"WAYLAND_DISPLAY": "wayland-0"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_session_anywhere_lists_what_was_asked(monkeypatch):
|
||||||
|
from antigravity_provider import agy_subprocess as m
|
||||||
|
|
||||||
|
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
monkeypatch.setattr(m.shutil, "which", lambda _n: None)
|
||||||
|
|
||||||
|
found, checked = m.detect_graphical_session()
|
||||||
|
|
||||||
|
assert found == {}
|
||||||
|
assert any("loginctl (не найден)" in item for item in checked)
|
||||||
319
tests/test_a58_agy_eligibility_state.py
Normal file
319
tests/test_a58_agy_eligibility_state.py
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
"""Tests for Task A58: Antigravity CLI (agy) Eligibility State Detection and Controls.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Read-only machine code inspection for x86-64 and arm64 signatures.
|
||||||
|
- Detection of all 3 states: check_removed, check_active, unknown (with exact reasons).
|
||||||
|
- Strict read-only guarantee (SHA256 binary integrity preserved).
|
||||||
|
- State change event bus publishing and audit logging without continuous polling loops.
|
||||||
|
- Action handlers for run_agy_patch_script, run_agy_update, and refresh_agy_eligibility.
|
||||||
|
- Web API endpoints (/api/snapshot, /api/settings) returning agy_eligibility.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.router.agy_eligibility_service import (
|
||||||
|
STATUS_CHECK_ACTIVE,
|
||||||
|
STATUS_CHECK_REMOVED,
|
||||||
|
STATUS_UNKNOWN,
|
||||||
|
AgyEligibilityService,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.event_bus import (
|
||||||
|
EVENT_AGY_ELIGIBILITY_CHANGED,
|
||||||
|
EventBus,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.settings_service import (
|
||||||
|
get_hub_settings,
|
||||||
|
save_hub_settings,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.unified_health import EventLogService
|
||||||
|
from antigravity_provider.router.web.server import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_service():
|
||||||
|
"""Reset singleton cache before and after each test."""
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
service.invalidate_cache()
|
||||||
|
service._last_status = None
|
||||||
|
service._last_status_label = None
|
||||||
|
service._last_sha256 = None
|
||||||
|
service._last_binary_path = None
|
||||||
|
yield
|
||||||
|
service.invalidate_cache()
|
||||||
|
service._last_status = None
|
||||||
|
service._last_status_label = None
|
||||||
|
service._last_sha256 = None
|
||||||
|
service._last_binary_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_agy_binary_if_present():
|
||||||
|
"""Verify inspection against the real host binary at ~/.local/bin/agy if available."""
|
||||||
|
real_path = Path.home() / ".local" / "bin" / "agy"
|
||||||
|
if not real_path.is_file():
|
||||||
|
pytest.skip("Real agy binary not found on this host")
|
||||||
|
|
||||||
|
with open(real_path, "rb") as f:
|
||||||
|
original_bytes = f.read()
|
||||||
|
expected_sha = hashlib.sha256(original_bytes).hexdigest()
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=real_path)
|
||||||
|
|
||||||
|
assert res["binary_path"] == str(real_path)
|
||||||
|
assert res["binary_sha256"] == expected_sha
|
||||||
|
assert res["binary_size_bytes"] == len(original_bytes)
|
||||||
|
assert res["status"] in (STATUS_CHECK_ACTIVE, STATUS_CHECK_REMOVED, STATUS_UNKNOWN)
|
||||||
|
assert res["status_label_ru"] != ""
|
||||||
|
|
||||||
|
# Verify 100% read-only integrity: file was not modified
|
||||||
|
with open(real_path, "rb") as f:
|
||||||
|
after_bytes = f.read()
|
||||||
|
assert hashlib.sha256(after_bytes).hexdigest() == expected_sha
|
||||||
|
|
||||||
|
|
||||||
|
def test_x86_unpatched_signature_detected(tmp_path: Path):
|
||||||
|
"""P0-1 & P0-2: Unpatched x86-64 binary matches check_active."""
|
||||||
|
# Machine code: test rax,rax ; je ; cmp byte [rax+8], 0 ; jne ; call
|
||||||
|
fake_code = (
|
||||||
|
b"\x90\x90"
|
||||||
|
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x80\x78\x08\x00\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
|
||||||
|
b"\x90\x90"
|
||||||
|
)
|
||||||
|
fake_bin = tmp_path / "fake_agy_x86_orig"
|
||||||
|
fake_bin.write_bytes(fake_code)
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
|
||||||
|
|
||||||
|
assert res["status"] == STATUS_CHECK_ACTIVE
|
||||||
|
assert "Проверка на месте" in res["status_label_ru"]
|
||||||
|
assert "Аккаунт может отклоняться" in res["detail_ru"]
|
||||||
|
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_x86_patched_signature_detected(tmp_path: Path):
|
||||||
|
"""P0-1 & P0-2: Patched x86-64 binary matches check_removed."""
|
||||||
|
# Machine code: test rax,rax ; je ; test rax,rax ; nop ; jne ; call
|
||||||
|
fake_code = (
|
||||||
|
b"\x90\x90"
|
||||||
|
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x48\x85\xc0\x90\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
|
||||||
|
b"\x90\x90"
|
||||||
|
)
|
||||||
|
fake_bin = tmp_path / "fake_agy_x86_patched"
|
||||||
|
fake_bin.write_bytes(fake_code)
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
|
||||||
|
|
||||||
|
assert res["status"] == STATUS_CHECK_REMOVED
|
||||||
|
assert "Проверка снята" in res["status_label_ru"]
|
||||||
|
assert "Патч начальной проверки" in res["detail_ru"]
|
||||||
|
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm64_signatures_detected(tmp_path: Path):
|
||||||
|
"""P0-1 & P0-2: ARM64 original and patched detection."""
|
||||||
|
# ARM64 unpatched
|
||||||
|
arm_orig_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x00\x20\x40\x39\x00\x00TRAILER"
|
||||||
|
bin_orig = tmp_path / "fake_agy_arm64_orig"
|
||||||
|
bin_orig.write_bytes(arm_orig_code)
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res_orig = service.check_eligibility_state(force=True, custom_binary_path=bin_orig)
|
||||||
|
assert res_orig["status"] == STATUS_CHECK_ACTIVE
|
||||||
|
assert "Проверка на месте" in res_orig["status_label_ru"]
|
||||||
|
|
||||||
|
# ARM64 patched (NOP)
|
||||||
|
arm_patch_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x1f\x20\x03\xd5\x00\x00TRAILER"
|
||||||
|
bin_patch = tmp_path / "fake_agy_arm64_patch"
|
||||||
|
bin_patch.write_bytes(arm_patch_code)
|
||||||
|
|
||||||
|
service.invalidate_cache()
|
||||||
|
res_patch = service.check_eligibility_state(force=True, custom_binary_path=bin_patch)
|
||||||
|
assert res_patch["status"] == STATUS_CHECK_REMOVED
|
||||||
|
assert "Проверка снята" in res_patch["status_label_ru"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_state_unsupported_binary(tmp_path: Path):
|
||||||
|
"""P0-2: Unsupported binary returns status 'unknown' with truthful reason."""
|
||||||
|
fake_code = b"Hello, this is a completely different binary without signatures."
|
||||||
|
fake_bin = tmp_path / "fake_agy_unknown"
|
||||||
|
fake_bin.write_bytes(fake_code)
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
|
||||||
|
|
||||||
|
assert res["status"] == STATUS_UNKNOWN
|
||||||
|
assert "Н/Д: сигнатура проверки не найдена" in res["status_label_ru"]
|
||||||
|
assert "неподдерживаемая версия" in res["detail_ru"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_state_missing_file(tmp_path: Path):
|
||||||
|
"""P0-2: Missing binary path returns status 'unknown'."""
|
||||||
|
missing_bin = tmp_path / "non_existent_agy_binary"
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=missing_bin)
|
||||||
|
|
||||||
|
assert res["status"] == STATUS_UNKNOWN
|
||||||
|
assert "Н/Д: файл не найден" in res["status_label_ru"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_guarantee(tmp_path: Path):
|
||||||
|
"""Strictly guarantees that checking eligibility NEVER modifies the file."""
|
||||||
|
fake_code = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
|
||||||
|
fake_bin = tmp_path / "test_bin_readonly"
|
||||||
|
fake_bin.write_bytes(fake_code)
|
||||||
|
mtime_before = fake_bin.stat().st_mtime
|
||||||
|
sha_before = hashlib.sha256(fake_code).hexdigest()
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
|
||||||
|
|
||||||
|
assert res["status"] == STATUS_CHECK_ACTIVE
|
||||||
|
assert fake_bin.read_bytes() == fake_code
|
||||||
|
assert fake_bin.stat().st_mtime == mtime_before
|
||||||
|
assert hashlib.sha256(fake_bin.read_bytes()).hexdigest() == sha_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_change_event_and_audit_logging(tmp_path: Path):
|
||||||
|
"""P0-3: State transition publishes EventBus event and logs to EventLogService."""
|
||||||
|
events_received = []
|
||||||
|
|
||||||
|
def on_event(event_name, data):
|
||||||
|
events_received.append((event_name, data))
|
||||||
|
|
||||||
|
EventBus.get().subscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
|
||||||
|
|
||||||
|
bin_path = tmp_path / "mutable_test_agy"
|
||||||
|
|
||||||
|
# Step 1: Initial state is check_active
|
||||||
|
code_active = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
|
||||||
|
bin_path.write_bytes(code_active)
|
||||||
|
|
||||||
|
service = AgyEligibilityService.get()
|
||||||
|
res1 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
|
||||||
|
assert res1["status"] == STATUS_CHECK_ACTIVE
|
||||||
|
|
||||||
|
# Step 2: Simulate owner running patch script -> binary transitions to check_removed
|
||||||
|
code_patched = b"\x48\x85\xc0\x74\x02\x48\x85\xc0\x90\x75\x02"
|
||||||
|
bin_path.write_bytes(code_patched)
|
||||||
|
|
||||||
|
service.invalidate_cache()
|
||||||
|
res2 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
|
||||||
|
assert res2["status"] == STATUS_CHECK_REMOVED
|
||||||
|
|
||||||
|
# EventBus must have received the transition event
|
||||||
|
assert len(events_received) == 1
|
||||||
|
assert events_received[0][0] == EVENT_AGY_ELIGIBILITY_CHANGED
|
||||||
|
assert events_received[0][1]["status"] == STATUS_CHECK_REMOVED
|
||||||
|
|
||||||
|
# EventLogService must contain security audit log
|
||||||
|
logs = EventLogService.get().get_events(category="security")
|
||||||
|
found_logs = [log for log in logs if getattr(log, "action", None) == "agy_eligibility_change" or (isinstance(log, dict) and log.get("action") == "agy_eligibility_change")]
|
||||||
|
assert len(found_logs) >= 1
|
||||||
|
last_log = found_logs[-1]
|
||||||
|
msg = getattr(last_log, "message", None) if not isinstance(last_log, dict) else last_log.get("message", "")
|
||||||
|
assert "Проверка на месте → Проверка снята" in msg
|
||||||
|
|
||||||
|
EventBus.get().unsubscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_run_agy_patch_script_not_configured():
|
||||||
|
"""P0-4: When patch script path is empty, action returns informative message."""
|
||||||
|
client = TestClient(app)
|
||||||
|
# Ensure setting is empty
|
||||||
|
save_hub_settings({"agy_patch_script_path": ""})
|
||||||
|
|
||||||
|
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "Н/Д: путь к сценарию патча не указан в настройках" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_run_agy_patch_script_invalid_path(tmp_path: Path):
|
||||||
|
"""P0-4: When patch script file does not exist, action returns error."""
|
||||||
|
client = TestClient(app)
|
||||||
|
missing_script = tmp_path / "non_existent_patch.sh"
|
||||||
|
save_hub_settings({"agy_patch_script_path": str(missing_script)})
|
||||||
|
|
||||||
|
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "Файл сценария не найден или недоступен" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_run_agy_patch_script_success(tmp_path: Path):
|
||||||
|
"""P0-4: Valid patch script launches in terminal and returns new eligibility state."""
|
||||||
|
client = TestClient(app)
|
||||||
|
patch_script = tmp_path / "fake_patch.sh"
|
||||||
|
patch_script.write_text("#!/bin/sh\necho Patched\n")
|
||||||
|
patch_script.chmod(0o755)
|
||||||
|
|
||||||
|
save_hub_settings({"agy_patch_script_path": str(patch_script)})
|
||||||
|
|
||||||
|
with patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
|
||||||
|
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
|
||||||
|
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "Сценарий патча запущен" in data["message"]
|
||||||
|
assert "eligibility" in data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_run_agy_update():
|
||||||
|
"""P0-5: Action run_agy_update launches agy update in terminal."""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
with patch("antigravity_provider.agy_subprocess.get_agy_exe", return_value="/bin/agy"), \
|
||||||
|
patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
|
||||||
|
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
|
||||||
|
response = client.post("/api/action", json={"action": "run_agy_update"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "Обновление agy запущено" in data["message"]
|
||||||
|
assert "eligibility" in data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_refresh_agy_eligibility():
|
||||||
|
"""Action refresh_agy_eligibility triggers cache invalidation and recheck."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/api/action", json={"action": "refresh_agy_eligibility"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "Состояние проверки agy" in data["message"]
|
||||||
|
assert "status" in data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_api_snapshot_and_settings_include_eligibility():
|
||||||
|
"""Verify /api/snapshot and /api/settings contain agy_eligibility payload."""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Snapshot endpoint
|
||||||
|
snap_resp = client.get("/api/snapshot")
|
||||||
|
assert snap_resp.status_code == 200
|
||||||
|
snap_data = snap_resp.json()
|
||||||
|
assert "agy_eligibility" in snap_data
|
||||||
|
elig = snap_data["agy_eligibility"]
|
||||||
|
assert "status" in elig
|
||||||
|
assert "status_label_ru" in elig
|
||||||
|
assert "binary_sha256" in elig
|
||||||
|
|
||||||
|
# Settings endpoint
|
||||||
|
sett_resp = client.get("/api/settings")
|
||||||
|
assert sett_resp.status_code == 200
|
||||||
|
sett_data = sett_resp.json()
|
||||||
|
assert "agy_eligibility" in sett_data
|
||||||
|
assert "agy_patch_script_path" in sett_data
|
||||||
614
tests/test_a59_visible_update.py
Normal file
614
tests/test_a59_visible_update.py
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
"""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()
|
||||||
77
tests/test_add_account_does_not_block.py
Normal file
77
tests/test_add_account_does_not_block.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Подключение аккаунта не должно ждать опроса провайдера.
|
||||||
|
|
||||||
|
Мастер владельца замирал на шаге 3 с надписью «сохранение аккаунта и запуск
|
||||||
|
проверки». Зависанием это не было: действие честно ждало проверку Antigravity
|
||||||
|
через CLI — до 90 с на захват замка профиля, до 65 на каталог моделей и до 90
|
||||||
|
на пробный вызов. Около четырёх минут молчания при обещанной «минуте на этап».
|
||||||
|
|
||||||
|
Сохранение и назначение роли занимают миллисекунды. Опрос провайдера идёт в
|
||||||
|
фоне, а карточка обновляется, когда он закончится.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_authenticated_antigravity():
|
||||||
|
with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True}), \
|
||||||
|
patch.object(ProfileAuthManager, "load_profile_auth", return_value={"email": "x@gmail.com"}), \
|
||||||
|
patch.object(ProfileAuthManager, "save_profile_auth", return_value="/tmp/auth.json"), \
|
||||||
|
patch.object(AutoAssigner, "ensure_profile_definition", return_value=(True, "ok")), \
|
||||||
|
patch.object(AutoAssigner, "assign_profile_to_role", return_value=(True, "ok")):
|
||||||
|
return ActionExecutor.execute(
|
||||||
|
"add_account",
|
||||||
|
{"provider": "antigravity", "profile_id": "ag-1", "target_role": "orchestrator"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_returns_without_waiting_for_provider():
|
||||||
|
slow_call = []
|
||||||
|
|
||||||
|
def _slow_check_now(self, provider, profile_id, models_only=False):
|
||||||
|
slow_call.append(profile_id)
|
||||||
|
time.sleep(5) # изображаем опрос провайдера
|
||||||
|
return {"ok": True, "message": "проверено"}
|
||||||
|
|
||||||
|
with patch.object(AccountProbeService, "schedule", return_value=True) as scheduled, \
|
||||||
|
patch.object(AccountProbeService, "check_now", _slow_check_now):
|
||||||
|
started = time.monotonic()
|
||||||
|
res = _connect_authenticated_antigravity()
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
|
||||||
|
assert res["ok"], res
|
||||||
|
assert not slow_call, "действие не должно ждать опрос провайдера"
|
||||||
|
assert scheduled.called, "проверка обязана быть поставлена в фон"
|
||||||
|
assert elapsed < 2, f"ответ занял {elapsed:.1f} с"
|
||||||
|
assert res["data"]["check"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_reaches_owner_when_background_service_is_down():
|
||||||
|
"""Служба не работает — проверяем здесь, иначе результата не будет вовсе."""
|
||||||
|
with patch.object(AccountProbeService, "schedule", return_value=False), \
|
||||||
|
patch.object(AccountProbeService, "state", return_value={"state": "never_checked"}), \
|
||||||
|
patch.object(
|
||||||
|
AccountProbeService,
|
||||||
|
"check_now",
|
||||||
|
return_value={"ok": False, "message": "провайдер отказал", "data": {}},
|
||||||
|
) as checked:
|
||||||
|
res = _connect_authenticated_antigravity()
|
||||||
|
|
||||||
|
assert checked.called, "без фоновой службы проверка выполняется на месте"
|
||||||
|
assert res["message"] == "провайдер отказал"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_already_running_is_not_awaited():
|
||||||
|
with patch.object(AccountProbeService, "schedule", return_value=False), \
|
||||||
|
patch.object(AccountProbeService, "state", return_value={"state": "checking"}), \
|
||||||
|
patch.object(AccountProbeService, "check_now") as checked:
|
||||||
|
res = _connect_authenticated_antigravity()
|
||||||
|
|
||||||
|
assert not checked.called, "уже идущую проверку не ждём и не дублируем"
|
||||||
|
assert res["ok"] and res["data"]["check"] == "running"
|
||||||
96
tests/test_agy_cli_token_file.py
Normal file
96
tests/test_agy_cli_token_file.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""agy 2.0 читает вход не из oauth_creds.json.
|
||||||
|
|
||||||
|
Сравнение рабочего профиля владельца с неработающим показало единственное
|
||||||
|
значимое различие: у рабочего есть `.gemini/antigravity-cli/antigravity-oauth-token`,
|
||||||
|
у неработающего — только `.gemini/oauth_creds.json`. Оба файла с токенами были
|
||||||
|
на месте и одинакового размера, но `agy models` отвечал «Please sign in to view
|
||||||
|
available models».
|
||||||
|
|
||||||
|
Формат подтверждён на машине владельца: верхний уровень — ключи `auth_method`
|
||||||
|
и `token`, значение `auth_method` — `consumer`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path):
|
||||||
|
return ProfileAuthManager.write_agy_oauth_creds(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"access_token": "ya29.TEST-ACCESS",
|
||||||
|
"refresh_token": "1//TEST-REFRESH",
|
||||||
|
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"id_token": "eyJTEST",
|
||||||
|
"expiry_date": int((time.time() + 3600) * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_antigravity_cli_token_written(tmp_path):
|
||||||
|
_write(tmp_path)
|
||||||
|
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||||
|
assert target.is_file(), "agy читает вход именно отсюда"
|
||||||
|
|
||||||
|
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||||
|
assert sorted(payload) == ["auth_method", "token"]
|
||||||
|
assert payload["auth_method"] == "consumer"
|
||||||
|
assert payload["token"]["access_token"] == "ya29.TEST-ACCESS"
|
||||||
|
assert payload["token"]["refresh_token"] == "1//TEST-REFRESH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry_written_in_both_formats(tmp_path):
|
||||||
|
_write(tmp_path)
|
||||||
|
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||||
|
token = json.loads(target.read_text(encoding="utf-8"))["token"]
|
||||||
|
# Node-формат — миллисекунды, Go-шный oauth2.Token — строка RFC3339.
|
||||||
|
assert isinstance(token["expiry_date"], int)
|
||||||
|
assert token["expiry"].endswith("Z")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemini_creds_still_written(tmp_path):
|
||||||
|
"""Прежний файл остаётся: его читают другие части agy и сам хаб."""
|
||||||
|
target_file = _write(tmp_path)
|
||||||
|
assert target_file == tmp_path / ".gemini" / "oauth_creds.json"
|
||||||
|
creds = json.loads(target_file.read_text(encoding="utf-8"))
|
||||||
|
assert creds["access_token"] == "ya29.TEST-ACCESS"
|
||||||
|
assert "auth_method" not in creds
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_method_taken_from_working_neighbour(tmp_path):
|
||||||
|
"""Значение способа входа берётся у уже работающего профиля, если он есть."""
|
||||||
|
neighbour = tmp_path / "ag-working" / ".gemini" / "antigravity-cli"
|
||||||
|
neighbour.mkdir(parents=True)
|
||||||
|
(neighbour / "antigravity-oauth-token").write_text(
|
||||||
|
json.dumps({"auth_method": "workforce", "token": {}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = tmp_path / "ag-new"
|
||||||
|
profile.mkdir()
|
||||||
|
_write(profile)
|
||||||
|
|
||||||
|
payload = json.loads(
|
||||||
|
(profile / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert payload["auth_method"] == "workforce"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_login_writes_nothing(tmp_path):
|
||||||
|
"""Вход без токена не должен оставлять ни одного файла учётных данных."""
|
||||||
|
try:
|
||||||
|
ProfileAuthManager.write_agy_oauth_creds(tmp_path, {"email": "x@y.z"})
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else: # pragma: no cover - защита от возврата прежнего поведения
|
||||||
|
raise AssertionError("пустой вход обязан отказывать")
|
||||||
|
|
||||||
|
assert not (tmp_path / ".gemini" / "oauth_creds.json").exists()
|
||||||
|
assert not (
|
||||||
|
tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
|
||||||
|
).exists()
|
||||||
|
|
@ -116,7 +116,7 @@ def test_check_profile_native_auth_status(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||||
|
|
||||||
slot = "ag-w3"
|
slot = "ag-w3"
|
||||||
pdir = get_profile_dir(slot, "antigravity")
|
pdir = get_profile_dir(slot, "antigravity", create=True)
|
||||||
gemini_dir = pdir / ".gemini"
|
gemini_dir = pdir / ".gemini"
|
||||||
gemini_dir.mkdir(parents=True, exist_ok=True)
|
gemini_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
|
||||||
|
|
||||||
# Connect first slot
|
# Connect first slot
|
||||||
AutoAssigner.ensure_profile_definition("antigravity", first_slot)
|
AutoAssigner.ensure_profile_definition("antigravity", first_slot)
|
||||||
pdir = get_profile_dir(first_slot, "antigravity")
|
pdir = get_profile_dir(first_slot, "antigravity", create=True)
|
||||||
(pdir / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user1@gmail.com"}))
|
(pdir / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user1@gmail.com"}))
|
||||||
|
|
||||||
# Next free slot
|
# Next free slot
|
||||||
|
|
@ -178,7 +178,7 @@ def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
|
||||||
|
|
||||||
# Connect second slot
|
# Connect second slot
|
||||||
AutoAssigner.ensure_profile_definition("antigravity", second_slot)
|
AutoAssigner.ensure_profile_definition("antigravity", second_slot)
|
||||||
pdir2 = get_profile_dir(second_slot, "antigravity")
|
pdir2 = get_profile_dir(second_slot, "antigravity", create=True)
|
||||||
(pdir2 / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user2@gmail.com"}))
|
(pdir2 / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user2@gmail.com"}))
|
||||||
|
|
||||||
# Next free slot
|
# Next free slot
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ def test_ollama_invoke_default_base_url():
|
||||||
|
|
||||||
assert resp["choices"][0]["message"]["content"] == "pong"
|
assert resp["choices"][0]["message"]["content"] == "pong"
|
||||||
req_arg = mock_urlopen.call_args[0][0]
|
req_arg = mock_urlopen.call_args[0][0]
|
||||||
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/chat/completions"
|
assert req_arg.get_full_url() == f"{DEFAULT_OLLAMA_BASE_URL}/v1/chat/completions"
|
||||||
assert req_arg.get_method() == "POST"
|
assert req_arg.get_method() == "POST"
|
||||||
assert "Authorization" not in req_arg.headers
|
assert "Authorization" not in req_arg.headers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -39,12 +40,29 @@ 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
|
# Set up mock Hermes Agent structure in temp home pointing to the active venv.
|
||||||
|
#
|
||||||
|
# Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку
|
||||||
|
# зависимостей на установленной машине бессмысленной: файл существовал,
|
||||||
|
# но не был исполняемым питоном, и по-настоящему установщик его не
|
||||||
|
# запускал ни разу. Найдено прогоном на настоящей машине (A61): junction
|
||||||
|
# (или symlink) на реальный venv делает эту проверку осмысленной —
|
||||||
|
# запускается настоящий интерпретатор.
|
||||||
agent_dir = tmp_path / "hermes" / "hermes-agent"
|
agent_dir = tmp_path / "hermes" / "hermes-agent"
|
||||||
venv_scripts = agent_dir / "venv" / "Scripts"
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
venv_scripts.mkdir(parents=True, exist_ok=True)
|
real_venv = Path(sys.prefix)
|
||||||
(venv_scripts / "python.exe").touch()
|
target_venv = agent_dir / "venv"
|
||||||
(venv_scripts / "hermes.exe").touch()
|
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 / "python.exe").touch()
|
||||||
|
(venv_scripts / "hermes.exe").touch()
|
||||||
|
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["HERMES_HOME"] = str(tmp_path / "hermes")
|
env["HERMES_HOME"] = str(tmp_path / "hermes")
|
||||||
|
|
@ -54,7 +72,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}"
|
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}. Stdout: {res.stdout}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.installer
|
@pytest.mark.installer
|
||||||
|
|
@ -72,3 +90,50 @@ def test_silent_installer_fails_without_hermes(tmp_path):
|
||||||
|
|
||||||
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 failure for missing Hermes, got {res.returncode}"
|
assert res.returncode != 0, f"Expected failure for missing Hermes, got {res.returncode}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Установка на Linux: sudo и защищённый системный Python ──
|
||||||
|
#
|
||||||
|
# Установка на сервере владельца упала так: запуск через sudo увёл всё в
|
||||||
|
# /root/.hermes, венв Hermes там не нашёлся, установщик взял /usr/bin/python3,
|
||||||
|
# а он в Ubuntu 24.04 помечен EXTERNALLY-MANAGED и отклоняет pip. Проверка
|
||||||
|
# после установки честно упала на «No module named 'fastapi'».
|
||||||
|
|
||||||
|
def _linux_installer_text() -> str:
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
return (root / "installer" / "install-linux.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_installer_refuses_sudo():
|
||||||
|
text = _linux_installer_text()
|
||||||
|
assert "SUDO_USER" in text, "запуск через sudo обязан распознаваться"
|
||||||
|
assert "HERMES_ALLOW_ROOT" in text, "должен быть осознанный способ обойти отказ"
|
||||||
|
assert "exit 3" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_installer_creates_own_venv_when_system_python_is_managed():
|
||||||
|
text = _linux_installer_text()
|
||||||
|
assert "EXTERNALLY-MANAGED" in text, "PEP 668 должен распознаваться"
|
||||||
|
assert "-m venv" in text, "ответ на PEP 668 — собственное окружение"
|
||||||
|
# --break-system-packages ломает питон всей машины: имя флага не
|
||||||
|
# преувеличивает. Упоминание в пояснении допустимо, применение — нет,
|
||||||
|
# поэтому смотрим только на исполняемые строки.
|
||||||
|
code_lines = [
|
||||||
|
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
assert not any("--break-system-packages" in line for line in code_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_installer_fails_loudly_on_missing_deps():
|
||||||
|
"""Раньше pip падал, а установка продолжалась — до отказа на проверке."""
|
||||||
|
text = _linux_installer_text()
|
||||||
|
assert "exit 12" in text and "exit 13" in text
|
||||||
|
assert "|| true" not in text.split("Installing required packages")[1][:400]
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_launcher_knows_about_installer_venv():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
launcher = (root / "launcher" / "hermes-hub-web.sh").read_text(encoding="utf-8")
|
||||||
|
assert '$HERMES_HOME/venv/bin/python3' in launcher, (
|
||||||
|
"иначе запуск уйдёт на системный python, где зависимостей нет"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,17 @@ 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."""
|
||||||
|
|
@ -111,3 +122,49 @@ 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 не убирает лаунчер остановки"
|
||||||
|
|
|
||||||
|
|
@ -238,10 +238,23 @@ class TestRouterEngineFailover:
|
||||||
class TestAntigravityIsolation:
|
class TestAntigravityIsolation:
|
||||||
"""Test environment isolation for Antigravity profiles."""
|
"""Test environment isolation for Antigravity profiles."""
|
||||||
|
|
||||||
def test_profile_env_dir_creation(self):
|
def test_profile_env_dir_is_isolated_and_not_created_on_lookup(self):
|
||||||
|
"""Запрос пути не должен создавать каталог.
|
||||||
|
|
||||||
|
Прежде создавал, и любой обход списка стандартных слотов
|
||||||
|
(ag-orch-primary, ag-1..ag-20, ag-w1..ag-w10) материализовал их все:
|
||||||
|
у владельца накопилось 25 каталогов при единицах заведённых вручную.
|
||||||
|
"""
|
||||||
pdir = get_profile_env_dir("ag-w1")
|
pdir = get_profile_env_dir("ag-w1")
|
||||||
assert pdir.exists()
|
|
||||||
assert "ag-w1" in str(pdir)
|
assert "ag-w1" in str(pdir)
|
||||||
|
assert "agy_profiles" in str(pdir)
|
||||||
|
assert not pdir.exists(), "спросили путь — каталог появиться не должен"
|
||||||
|
|
||||||
|
from antigravity_provider.paths import get_profile_dir
|
||||||
|
|
||||||
|
created = get_profile_dir("ag-w1", "antigravity", create=True)
|
||||||
|
assert created.exists()
|
||||||
|
assert created == pdir
|
||||||
|
|
||||||
|
|
||||||
class TestRouterCLI:
|
class TestRouterCLI:
|
||||||
|
|
|
||||||
110
tests/test_native_login_registers_account.py
Normal file
110
tests/test_native_login_registers_account.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Вход через терминал должен регистрировать аккаунт, а не только записывать ключи.
|
||||||
|
|
||||||
|
Владелец: «в программе нет аккаунтов. я добавил первый… аккаунты так и не
|
||||||
|
появились». При этом вход проходил, 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,3 +461,251 @@ 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 без нужной строки признан достаточным"
|
||||||
|
|
|
||||||
113
tests/test_provider_proxy.py
Normal file
113
tests/test_provider_proxy.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Обход проверки доступности по стране — через прокси, а не патч бинарника.
|
||||||
|
|
||||||
|
Вход владельца через терминал прошёл: agy запустился в изолированном каталоге и
|
||||||
|
опознал аккаунт. Отказал Google: «Eligibility check failed: Your current account
|
||||||
|
is not eligible for Antigravity, because it is not currently available in your
|
||||||
|
location». Проверка смотрит на адрес выхода.
|
||||||
|
|
||||||
|
Правильный ответ — выйти через разрешённую страну. У владельца несколько узлов
|
||||||
|
в разных странах, поэтому адрес задаётся и общий, и на каждый профиль отдельно.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.agy_subprocess import (
|
||||||
|
proxy_env_overrides,
|
||||||
|
resolve_provider_proxy,
|
||||||
|
write_login_helper,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.settings_service import _normalize_proxy_url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"given, expected",
|
||||||
|
[
|
||||||
|
("socks5://127.0.0.1:1080", "socks5://127.0.0.1:1080"),
|
||||||
|
("127.0.0.1:1080", "socks5://127.0.0.1:1080"),
|
||||||
|
("http://proxy.example.com:8080", "http://proxy.example.com:8080"),
|
||||||
|
("socks5h://10.0.0.5:1080", "socks5h://10.0.0.5:1080"),
|
||||||
|
("", ""),
|
||||||
|
(" ", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_addresses_are_kept(given, expected):
|
||||||
|
assert _normalize_proxy_url(given) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"junk",
|
||||||
|
["мусор!!", "не адрес вовсе", "ftp://1.2.3.4:21", "socks5://1.2.3.4:99999", "://"],
|
||||||
|
)
|
||||||
|
def test_junk_is_rejected_not_dressed_up_with_a_scheme(junk):
|
||||||
|
"""Приписать socks5:// можно чему угодно.
|
||||||
|
|
||||||
|
Если бы мусор проходил, он выглядел бы принятым, а все обращения провайдера
|
||||||
|
молча ломались бы без внятной причины.
|
||||||
|
"""
|
||||||
|
assert _normalize_proxy_url(junk) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_covers_upper_lower_and_socks():
|
||||||
|
env = proxy_env_overrides("socks5://127.0.0.1:1080")
|
||||||
|
|
||||||
|
# Go читает HTTPS_PROXY, curl и многие библиотеки — https_proxy,
|
||||||
|
# ALL_PROXY нужен для socks5.
|
||||||
|
assert set(env) == {
|
||||||
|
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
||||||
|
"http_proxy", "https_proxy", "all_proxy",
|
||||||
|
}
|
||||||
|
assert set(env.values()) == {"socks5://127.0.0.1:1080"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_proxy_means_no_variables():
|
||||||
|
assert proxy_env_overrides("") == {}
|
||||||
|
assert proxy_env_overrides(" ") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_address_wins_over_the_common_one():
|
||||||
|
"""Узлы в разных странах: разным аккаунтам может требоваться разный выход."""
|
||||||
|
with patch(
|
||||||
|
"antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth",
|
||||||
|
return_value={"proxy_url": "socks5://nl.example:1080"},
|
||||||
|
), patch(
|
||||||
|
"antigravity_provider.router.settings_service.get_hub_settings",
|
||||||
|
return_value={"provider_proxy_url": "socks5://de.example:1080"},
|
||||||
|
):
|
||||||
|
assert resolve_provider_proxy("ag-1") == "socks5://nl.example:1080"
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_address_is_used_when_profile_has_none():
|
||||||
|
with patch(
|
||||||
|
"antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth",
|
||||||
|
return_value={},
|
||||||
|
), patch(
|
||||||
|
"antigravity_provider.router.settings_service.get_hub_settings",
|
||||||
|
return_value={"provider_proxy_url": "socks5://de.example:1080"},
|
||||||
|
):
|
||||||
|
assert resolve_provider_proxy("ag-1") == "socks5://de.example:1080"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_script_exports_the_proxy(tmp_path):
|
||||||
|
with patch(
|
||||||
|
"antigravity_provider.agy_subprocess.resolve_provider_proxy",
|
||||||
|
return_value="socks5://127.0.0.1:1080",
|
||||||
|
):
|
||||||
|
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-1").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "HTTPS_PROXY=" in body
|
||||||
|
assert "export " in body and "ALL_PROXY" in body
|
||||||
|
assert "socks5://127.0.0.1:1080" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_script_without_proxy_mentions_none(tmp_path):
|
||||||
|
with patch("antigravity_provider.agy_subprocess.resolve_provider_proxy", return_value=""):
|
||||||
|
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-1").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "PROXY" not in body
|
||||||
56
tests/test_running_build_is_visible.py
Normal file
56
tests/test_running_build_is_visible.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Владелец должен видеть, какой код сейчас работает.
|
||||||
|
|
||||||
|
На Linux строка сборки в боковой панели оставалась пустой: она заполнялась из
|
||||||
|
панели обновлений, а та подтягивается только при открытии. Понять, дошло ли
|
||||||
|
обновление, было нельзя.
|
||||||
|
|
||||||
|
Глубже: поле commit читается из манифеста на диске при каждом запросе, поэтому
|
||||||
|
переживший обновление процесс рапортует свежий номер при старом поведении.
|
||||||
|
Отличить сборки по нему невозможно — это уже подводило при разборе окон
|
||||||
|
консоли. running_commit снимается один раз, при старте процесса, и отвечает на
|
||||||
|
настоящий вопрос: какой код в памяти.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from antigravity_provider.router.web.server import (
|
||||||
|
PROCESS_STARTED_AT,
|
||||||
|
RUNNING_COMMIT,
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_the_running_build():
|
||||||
|
payload = TestClient(app).get("/api/health").json()
|
||||||
|
|
||||||
|
assert payload["running_commit"] == RUNNING_COMMIT
|
||||||
|
assert payload["started_at"] == PROCESS_STARTED_AT
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_reports_the_running_build():
|
||||||
|
snap = TestClient(app).get("/api/snapshot").json()
|
||||||
|
|
||||||
|
assert snap["running_commit"] == RUNNING_COMMIT
|
||||||
|
assert snap["started_at"] == PROCESS_STARTED_AT
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_commit_is_taken_once_and_does_not_follow_the_disk(monkeypatch):
|
||||||
|
"""Подмена манифеста на диске не должна менять номер работающей сборки."""
|
||||||
|
from antigravity_provider.router.web import server
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "get_installed_commit", lambda: "deadbee")
|
||||||
|
payload = TestClient(app).get("/api/health").json()
|
||||||
|
|
||||||
|
assert payload["commit"] == "deadbee", "поле commit отражает диск"
|
||||||
|
assert payload["running_commit"] == RUNNING_COMMIT, (
|
||||||
|
"а running_commit — то, что реально запущено"
|
||||||
|
)
|
||||||
|
assert payload["running_commit"] != "deadbee"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_time_is_plausible():
|
||||||
|
assert PROCESS_STARTED_AT > 0
|
||||||
|
assert PROCESS_STARTED_AT <= time.time()
|
||||||
61
tests/test_settings_screen_keeps_every_row.py
Normal file
61
tests/test_settings_screen_keeps_every_row.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Экран настроек не должен молча терять поля.
|
||||||
|
|
||||||
|
Владелец не нашёл настройку прокси. Она была в разметке, но исчезала при
|
||||||
|
открытии экрана: arrangeSettingsPanels пересобирает настройки по жёсткому
|
||||||
|
списку идентификаторов, а исходную карточку удаляет целиком — вместе со всем,
|
||||||
|
чего в списке нет.
|
||||||
|
|
||||||
|
Поле существовало в файле, но его не было на экране, и никакая проверка этого
|
||||||
|
не ловила.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
STATIC = Path(__file__).resolve().parent.parent / "src" / "antigravity_provider" / "router" / "web" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def markup() -> str:
|
||||||
|
return (STATIC / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def workspace() -> str:
|
||||||
|
return (STATIC / "workspace.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _grouped_ids(workspace: str) -> list[str]:
|
||||||
|
block = workspace.split("const groups = [")[1].split("];")[0]
|
||||||
|
return re.findall(r"'(setting-[a-z0-9-]+)'", block)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_grouped_id_exists_in_the_markup(markup, workspace):
|
||||||
|
for element_id in _grouped_ids(workspace):
|
||||||
|
assert f'id="{element_id}"' in markup, f"{element_id} перечислен в группах, но его нет в разметке"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_setting_is_reachable(markup, workspace):
|
||||||
|
assert 'id="setting-provider-proxy-url"' in markup
|
||||||
|
assert "setting-provider-proxy-url" in _grouped_ids(workspace), (
|
||||||
|
"негруппированная строка удаляется вместе с исходной карточкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_rows_are_kept_not_deleted(workspace):
|
||||||
|
"""Забыть настройку в списке можно, потерять её с экрана — нет."""
|
||||||
|
body = workspace.split("function arrangeSettingsPanels")[1].split("function ")[0]
|
||||||
|
|
||||||
|
assert "leftovers" in body, "остаток строк должен собираться, а не выбрасываться"
|
||||||
|
assert body.index("leftovers") < body.index("first.remove()"), (
|
||||||
|
"остаток надо забрать до удаления исходной карточки"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_element_does_not_break_the_screen(workspace):
|
||||||
|
"""Опечатка в списке не должна ронять сборку экрана целиком."""
|
||||||
|
body = workspace.split("function arrangeSettingsPanels")[1].split("function ")[0]
|
||||||
|
assert "?.closest" in body, "обращение к возможному null должно быть защищено"
|
||||||
118
tests/test_stale_verdict_vs_catalog.py
Normal file
118
tests/test_stale_verdict_vs_catalog.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Вердикт проверки не должен переживать более свежий каталог моделей.
|
||||||
|
|
||||||
|
В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и
|
||||||
|
«Получено 11 моделей · 13:25:53». Источники разные и обновляются независимо:
|
||||||
|
красная строка берётся из состояния проверки, список — из кэша каталога.
|
||||||
|
Каталог был получен ПОЗЖЕ отказа, то есть провайдер с тех пор ответил.
|
||||||
|
|
||||||
|
Утверждать отказ в такой ситуации нельзя. Объявлять аккаунт рабочим — тоже
|
||||||
|
не за что: проверка после этого не выполнялась. Честный ответ один: вердикт
|
||||||
|
устарел, нужна новая проверка.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from antigravity_provider.router.account_probe_service import AccountProbeService
|
||||||
|
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
|
||||||
|
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
|
from antigravity_provider.router.router_config import (
|
||||||
|
RouterConfig,
|
||||||
|
RouterProfileConfig,
|
||||||
|
save_router_config,
|
||||||
|
)
|
||||||
|
from antigravity_provider.router.unified_health import (
|
||||||
|
STATUS_NOT_TESTED,
|
||||||
|
STATUS_UNHEALTHY,
|
||||||
|
UnifiedHealthService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(failed_at: float, catalog_at: float | None, catalog_error=None):
|
||||||
|
cfg = RouterConfig()
|
||||||
|
cfg.profiles["ag-5"] = RouterProfileConfig(
|
||||||
|
profile_id="ag-5",
|
||||||
|
provider="antigravity",
|
||||||
|
account_id="ag-5",
|
||||||
|
preferred_models=[],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
save_router_config(cfg)
|
||||||
|
ProfileAuthManager.save_profile_auth(
|
||||||
|
"antigravity",
|
||||||
|
"ag-5",
|
||||||
|
{
|
||||||
|
# Запись входа Antigravity по праву отказывает без токена доступа,
|
||||||
|
# поэтому в проверке он должен быть настоящим по форме.
|
||||||
|
"token": {
|
||||||
|
"access_token": "ya29.TEST",
|
||||||
|
"refresh_token": "1//TEST",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expiry_date": int((time.time() + 3600) * 1000),
|
||||||
|
},
|
||||||
|
"email": "owner@gmail.com",
|
||||||
|
"auth_method": "oauth",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
probe = AccountProbeService.get()
|
||||||
|
with probe._lock:
|
||||||
|
probe._states["ag-5"] = {
|
||||||
|
"state": "failed",
|
||||||
|
"provider": "antigravity",
|
||||||
|
"checked_at": failed_at,
|
||||||
|
"message": "agy models: код 1. Please sign in to view available models.",
|
||||||
|
}
|
||||||
|
|
||||||
|
discovery = ModelDiscoveryService.get()
|
||||||
|
with discovery._cache_lock:
|
||||||
|
discovery._cache["antigravity:ag-5"] = {
|
||||||
|
"models": ["gemini-3.1-pro-high", "claude-sonnet-4-6"],
|
||||||
|
"discovered_at": catalog_at,
|
||||||
|
"error": catalog_error,
|
||||||
|
}
|
||||||
|
return UnifiedHealthService.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _view(service):
|
||||||
|
profiles = service.scan_all(force=True)
|
||||||
|
return next(p for p in profiles["antigravity"] if p.profile_id == "ag-5")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_newer_catalog_makes_the_failed_verdict_stale(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
view = _view(_prepare(failed_at=now - 60, catalog_at=now - 20))
|
||||||
|
|
||||||
|
assert view.health_state == STATUS_NOT_TESTED
|
||||||
|
assert "устарела" in view.health_label_ru
|
||||||
|
assert "Please sign in" not in view.health_label_ru
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_failure_stands_when_it_is_the_newer_fact(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
view = _view(_prepare(failed_at=now - 10, catalog_at=now - 300))
|
||||||
|
|
||||||
|
assert view.health_state == STATUS_UNHEALTHY
|
||||||
|
assert "Please sign in" in view.health_label_ru
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_failure_stands_when_catalog_itself_errored(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
view = _view(
|
||||||
|
_prepare(failed_at=now - 60, catalog_at=now - 20, catalog_error="Сервер отказал")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert view.health_state == STATUS_UNHEALTHY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_failure_stands_when_catalog_was_never_obtained(tmp_path):
|
||||||
|
now = time.time()
|
||||||
|
view = _view(_prepare(failed_at=now - 60, catalog_at=None))
|
||||||
|
|
||||||
|
assert view.health_state == STATUS_UNHEALTHY
|
||||||
|
|
@ -72,14 +72,27 @@ 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
|
#
|
||||||
assert store.refresh_skipped_total >= 1
|
# Проверяется именно отбрасывание, а не равенство поколений. 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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|
|
||||||
82
tests/test_ui_polling_is_silent.py
Normal file
82
tests/test_ui_polling_is_silent.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Опросы интерфейса не должны заливать владельца уведомлениями и кормить себя.
|
||||||
|
|
||||||
|
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще
|
||||||
|
ничего не вижу за ними».
|
||||||
|
|
||||||
|
Это был не таймер, а замкнутый круг: отрисовка настроек запускала опрос
|
||||||
|
состояния сжатия, executeAction на успехе вызывал fetchSnapshot, тот снова
|
||||||
|
перерисовывал настройки — и так без конца. Заодно каждый оборот показывал два
|
||||||
|
тоста: на запрос и на ответ.
|
||||||
|
|
||||||
|
Тем же путём заливали «poll_native_auth» во время входа.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
APP_JS = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "src" / "antigravity_provider" / "router" / "web" / "static" / "app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def source() -> str:
|
||||||
|
return APP_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_polling_actions_are_listed_as_silent(source):
|
||||||
|
assert "SILENT_ACTIONS" in source
|
||||||
|
for action in (
|
||||||
|
"get_compression_status",
|
||||||
|
"poll_native_auth",
|
||||||
|
"poll_redirect_auth",
|
||||||
|
"poll_device_auth",
|
||||||
|
):
|
||||||
|
assert f"'{action}'" in source, f"{action} обязан быть среди молчаливых"
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_actions_show_no_toasts(source):
|
||||||
|
"""Все четыре места, где executeAction показывает тост, под охраной silent."""
|
||||||
|
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
|
||||||
|
|
||||||
|
start, success, failure, network = (
|
||||||
|
body.split("try {")[0],
|
||||||
|
body.split("if (result.ok)")[1].split("} else {")[0],
|
||||||
|
body.split("} else {")[1].split("} catch")[0],
|
||||||
|
body.split("} catch")[1],
|
||||||
|
)
|
||||||
|
for part, where in (
|
||||||
|
(start, "начало действия"),
|
||||||
|
(success, "успех"),
|
||||||
|
(failure, "отказ"),
|
||||||
|
(network, "сбой сети"),
|
||||||
|
):
|
||||||
|
assert "showToast(" in part, f"место не найдено: {where}"
|
||||||
|
assert part.index("if (!silent)") < part.index("showToast("), (
|
||||||
|
f"тост без проверки молчаливости: {where}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_actions_do_not_refetch_the_snapshot(source):
|
||||||
|
"""Круг замыкался именно здесь: успех опроса тянул за собой снапшот."""
|
||||||
|
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
|
||||||
|
success_block = body.split("if (result.ok)")[1].split("} else {")[0]
|
||||||
|
assert "if (!silent)" in success_block
|
||||||
|
assert success_block.index("if (!silent)") < success_block.index("fetchSnapshot(")
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_render_does_not_start_a_poll(source):
|
||||||
|
"""Опрос из отрисовки убран — она идёт на каждом обновлении снапшота."""
|
||||||
|
render = source.split("function populateCompressorProfiles")[0]
|
||||||
|
render_tail = render[-2000:]
|
||||||
|
calls = re.findall(r"^\s*checkCompressionStatus\(\);", render_tail, re.MULTILINE)
|
||||||
|
assert not calls, "опрос из отрисовки настроек запускать нельзя"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_is_requested_when_the_screen_opens(source):
|
||||||
|
opening = source.split("if (viewName === 'settings')")[1][:400]
|
||||||
|
assert "checkCompressionStatus()" in opening
|
||||||
2
uv.lock
2
uv.lock
|
|
@ -297,7 +297,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermes-hub"
|
name = "hermes-hub"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue