diff --git a/agents/inbox/A54-accounts-fix.md b/agents/inbox/A54-accounts-fix.md new file mode 100644 index 0000000..a3bcd40 --- /dev/null +++ b/agents/inbox/A54-accounts-fix.md @@ -0,0 +1,169 @@ +# Задание A54: проверка аккаунтов не работает, окна консоли, закрытие программы + +## Дата поступления +2026-08-31 + +## База + +`origin/main` (`f0d06e4`). + +``` +git fetch origin --prune +git checkout -b antigravity/a54-accounts-fix origin/main +``` + +В `main` напрямую не пушить. + +## Порядок исполнения + +Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора. + +**Задание срочное.** После установки сборки `f0d06e4` владелец не может пользоваться программой: ни один аккаунт не проверяется, модели не подтягиваются, поверх окна выскакивают чёрные консоли. + +--- + +## Задача + +Владелец, дословно: «я так понял ни один аккаунт не подключается. Все проверки проходят с ошибкой, модели перестали нормально подтягиваться. Даже на локальных моделях». И отдельно: «в чём сложность-то?» — про OpenRouter и NVIDIA, которые не подключаются третье задание подряд. + +--- + +## Что проверено ревьюером — заново не выяснять + +### Кнопка «Проверить подключение» ничего не проверяет + +Воспроизведено вызовом: + +``` +check_account profile_id=local-1 + → ok=False + → «Фоновая служба проверки не запущена. Перезапустите веб-сервер.» +``` + +Действие **перекладывает работу на фоновую службу** вместо того, чтобы выполнить проверку. Если служба не поднялась, владелец получает отказ на каждом аккаунте. В интерфейсе это выглядит как «Тест завершился с ошибкой» и «Отказ выполнения действия» — второе вообще запасной текст на случай пустого сообщения, то есть причина до владельца не доходит. + +Служба включается в `web/server.py:496` внутри фонового потока. Любой сбой этого потока оставляет все проверки нерабочими, и узнать об этом нельзя. + +### Удаление аккаунта занимает полминуты + +Причина найдена: `_rescan_after_auth()` вызывает + +```python +HubStateStore.get().refresh(force_scan=True) +AccountProbeService.get().schedule_all() +``` + +то есть **принудительный полный пересбор всех провайдеров** с сетевыми запросами. Таймауты в сборщике квот — 15, 20 и 30 секунд, у Antigravity через CLI — 60. Удаление одного ключа ждёт опроса всех. + +### Окна консоли + +В `f0d06e4` скрытие окна добавлено к двум живым запускам `agy` и к остальным фоновым вызовам. Проверено, что `hidden_process_kwargs()` на Windows возвращает `CREATE_NO_WINDOW` и `SW_HIDE`. + +Окна у владельца остались. Наиболее вероятная причина: **старый процесс сервера пережил обновление**. Закрытие окна браузера сервер не останавливает, и после установки продолжает работать прежний код. Проверить это первым делом. + +`launch_native_agy_login` с `CREATE_NEW_CONSOLE` — мёртвый код, его никто не вызывает. Либо удалить, либо подключить к входу. + +### OpenRouter и NVIDIA + +Сохранение работает — проверено вызовом `add_account`, профиль создаётся с верным идентификатором, чужой слот отклоняется. Значит дело не в сохранении, а в том, что **после ввода ключа ничего не проверяется и модели не запрашиваются**, и владелец остаётся с пустым списком. + +--- + +## P0-1. Проверка выполняется, а не делегируется + +1. **«Проверить подключение» делает настоящий запрос к провайдеру здесь и сейчас** и возвращает результат. Фоновая служба — для периодической проверки, а не для ручной. +2. **Отказ невозможен из-за незапущенной службы.** Если фоновая служба нужна, но не работает, ручная проверка всё равно обязана отработать. +3. **Причина доходит до владельца.** Запасной текст «Отказ выполнения действия» означает пустое сообщение — таких путей быть не должно. +4. **Состояние службы видно** в «Состоянии системы»: работает или нет, когда был последний обход. + +## P0-2. Удаление и очистка + +1. **Удаление ключа не запускает полный пересбор.** Обновлять только затронутый профиль; полный обход — в фон, не блокируя ответ. +2. **Кнопка «Очистить все аккаунты»** с подтверждением и перечислением того, что будет удалено. +3. **Учётные данные Antigravity — под защитой A37.** Массовое удаление не должно затрагивать `~/.hermes/agy_profiles/` без явного отдельного подтверждения: повторный вход в два десятка аккаунтов делается вручную и стоит владельцу часов. + +## P0-3. Закрытие программы на Windows + +Владелец: «при нажатии на крестик спрашивать, закрыть программу или свернуть в фон. При закрытии полностью всё закрывает». + +1. **Диалог при закрытии**: закрыть полностью или свернуть в фон. +2. **Закрытие останавливает всё**: веб-сервер, фоновые опросы, дочерние процессы. После этого окон появляться не должно. +3. **Свёрнутое состояние видно** — значок в области уведомлений с пунктами «Открыть» и «Выход». +4. **Обновление не должно оставлять старый процесс**: перед установкой прежний сервер останавливается. Это вероятная причина того, что окна не исчезли после установки исправления. + +## P0-4. Подключение по ключу проверяется сразу + +Для `openrouter`, `nvidia`, `nvidia-nim` и прочих провайдеров с ключом: + +1. **После ввода ключа — немедленная проверка**: запрос к провайдеру, и его ответ показывается владельцу. +2. **Ключ неверен — сказать сразу**, не создавая профиль-пустышку. +3. **Ключ верен — тут же запросить модели** и дать выбрать предпочитаемую в том же окне. +4. **Не «сохранено», а «подключено и проверено»** — сообщение должно отражать, что именно произошло. + +## P0-5. Модели у локальных и Ollama + +1. **«Запросить список моделей» у локального профиля возвращает ошибку** — разобраться и починить. Локальный путь не требует ключа, отказ там означает дефект, а не отсутствие доступа. +2. **Ollama: список не грузится.** Локальные модели через `/api/tags` по адресу профиля; облачный каталог уже работает — не сломать. +3. **Отличать «сервер не отвечает» от «моделей нет»**: у владельца на Windows Ollama не запущена, и `WinError 10061` — это честный ответ, его надо показывать именно так, а не как ошибку обновления. + +## P0-6. Antigravity: было 14 моделей, стало 3 + +На прошлой сборке у аккаунтов Antigravity значилось «Получено 14 моделей» с перечнем. Сейчас в карточке три, статус «Не проверялся», а проверка завершается ошибкой. + +1. **Найти, где список сузился.** Проверить, не подменяется ли обнаруженный список предпочтениями профиля — эта ошибка уже была в инспекторе агента и чинилась в правках ревьюера. +2. **Число и время получения показывать** рядом со списком, как было. + +## P0-7. Проверка исполнением + +Тестов недостаточно: все перечисленные дефекты прошли через зелёный прогон. + +1. **Открыть хаб и нажать «Проверить подключение»** на локальном профиле, на Ollama и на Antigravity. Результат приложить скриншотами. +2. **Подключить OpenRouter с заведомо неверным ключом** и убедиться, что ошибка видна сразу; затем убедиться, что при верном ключе подтягиваются модели. +3. **Удалить аккаунт и замерить время** — должно быть быстро, без ожидания опроса всех провайдеров. +4. **Закрыть программу крестиком**, выбрать «закрыть», и убедиться, что процессов не осталось и окна не появляются. +5. **Проверить, что после обновления старый процесс не остаётся.** + +## P0-8. Аудит вторым проходом + +1. **Проверить, что ручная проверка работает при остановленной фоновой службе.** +2. **Искать оставшиеся пути с пустым сообщением об ошибке** — их не должно быть. +3. **Замерить удаление аккаунта** независимо. +4. **Проверить, что массовая очистка не трогает `~/.hermes/agy_profiles/`.** +5. **Проверить, что окна консоли не появляются** при работающей автопроверке. +6. **Побочные изменения** объяснить. +7. **Пропущенный пункт назвать пропущенным.** + +--- + +## Ограничения + +- Учётные данные и `~/.hermes/agy_profiles/` не трогать; массовое удаление — только с отдельным подтверждением. +- Автоматическую проверку не отключать ради тишины: чинить, а не убирать. +- Вёрстку A48 не ломать. +- Версию `0.1.1` не поднимать. +- Правило честности без исключений: причина отказа доходит до владельца текстом. + +## Критерии приёмки + +1. Ветка в `origin`, `git status` чист. +2. Ручная проверка выполняет запрос и возвращает результат даже при незапущенной фоновой службе; проверено. +3. Путей с пустым сообщением об ошибке не осталось. +4. Удаление аккаунта не ждёт полного обхода провайдеров; время замерено до и после. +5. Есть кнопка очистки всех аккаунтов с подтверждением; учётные данные Antigravity не затрагиваются без отдельного согласия. +6. Крестик спрашивает «закрыть или свернуть»; закрытие останавливает сервер и фоновые опросы; проверено отсутствием процессов. +7. Обновление не оставляет старый процесс. +8. Ввод ключа сразу проверяется, модели подтягиваются в том же окне. +9. Список моделей у локального профиля и Ollama работает; «сервер не отвечает» отличается от «моделей нет». +10. У Antigravity список моделей вернулся к полному; показано число и время получения. +11. Скриншоты проверок приложены. +12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **574**. +13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. + +## Главное + +Владелец поставил сборку и не может ей пользоваться: проверка отказывает на каждом аккаунте, модели не грузятся даже у локальных, удаление ключа занимает полминуты, а поверх окна выскакивают консоли. + +Общее у большинства этих дефектов одно: **действие не делает работу само, а перекладывает её на фоновую службу или на полный обход всех провайдеров**. Отсюда и отказы, и задержки. Чинить надо это, а не симптомы. + +## Порядок сдачи +Передать точный `FINAL_COMMIT_SHA`. \ No newline at end of file diff --git a/agents/reports/a54/README.md b/agents/reports/a54/README.md new file mode 100644 index 0000000..9212386 --- /dev/null +++ b/agents/reports/a54/README.md @@ -0,0 +1,40 @@ +# A54 — проверки аккаунтов и завершение приложения + +Дата: 2026-08-31. START_HEAD / origin/main на старте: `f0d06e499449564b3fb19c80a8bcd862ea895594`. +Ветка: `antigravity/a54-accounts-fix`. Версия остаётся 0.1.1. Точный FINAL_COMMIT_SHA будет указан при сдаче и в PR после окончательной проверки. + +## Что изменено + +- Ручная проверка выполняет запрос независимо от фонового планировщика; результаты и ошибки возвращаются сразу. Проверки одного профиля сериализованы, незавершённая inference после таймаута не запускается повторно. +- Модели запрашиваются отдельным действием без inference. Пустой каталог отличается от ошибки соединения. Облачный каталог Ollama сохранён. +- Новый ключ проверяется до создания профиля. OpenRouter: аутентифицированный `/key`, затем каталог. NVIDIA: каталог и минимальный запрос обнаруженной чат-модели. Ошибки не записывают профиль. В мастере можно выбрать полученную модель, результат сохраняется в состоянии проверки. +- Удаление и смена модели используют локальные изменения снапшота; подключение не ожидает общего опроса. Массовая очистка показывает точный список, требует подтверждения, отклоняет устаревший список, исключает Antigravity и ссылки на защищённые данные. +- AG: явный запрос каталога больше не возвращает пожизненный глобальный кэш другого профиля. Предпочтения подписаны отдельно; каталог не обрезается до восьми, видны число и время. Падение с 14 до 3 на машине владельца не воспроизведено напрямую: найдены глобальный кэш и отдельная строка предпочтений, оба исправлены без заявления о доказанной единственной причине. +- Windows: контроллер с tray «Открыть / Выход», выбор полного завершения при закрытии окна приложения; остановка процессов только этой установки. Установщик/PowerShell останавливают старое дерево перед копированием, сохраняя ветвь самого установщика; при обновлении установщик отвечает за перезапуск. Мёртвый запуск отдельной консоли AG удалён. Вывод сервера читается постоянно, чтобы перенаполненный pipe не останавливал сервер. + +## Проверки и их пределы + +- Linux: **598 passed / 1 skipped / 0 failed**, 4 deselected. Пропуск — Windows C# compiler отсутствует. `ruff check .`, Node DOM contracts и `node --check` успешны. +- Router verification **10/10**; release gate успешен. Итоговый Windows CI проверяется через draft PR, результат будет дописан после выполнения. +- Браузер: настоящий запрос к Qwen на локальном 8081; AG — явно синтетический профиль с 14 моделями; Ollama — HTTP стенд с пустым `/api/tags` и заведомо недоступный порт. Кнопки ручной проверки нажаты. +- OpenRouter в браузере: немедленный HTTP 401 при неверном тестовом ключе, успешный HTTP стенд возвращает каталог и выбор модели в том же мастере. Настоящий ключ OpenRouter/NVIDIA владельца не использовался. +- Удаление, 50 образцов обработчика: базовая медиана **0.329 мс**, A54 **0.324 мс**; максимумы 6.942 / 63.205 мс. Это не доказательство ускорения пользовательского сценария: полуминутную задержку Windows воспроизвести здесь нельзя. Лишние полные сканирования в последующих операциях устранены отдельно. См. `deletion-benchmark.json`. +- Защита AG, ссылки, устаревший preview, ручная проверка при disabled, непустые ошибки, serialization и сохранение результата проверены тестами в изолированных каталогах. + +## Не подтверждено исполнением + +Windows: диалог крестика, tray, отсутствие оставшихся процессов/консолей и обновление поверх запущенной старой установки требуют интерактивной проверки Windows. Компиляция/CI не заменяют её. В fallback обычного браузера его вкладка не отслеживается как окно приложения; выход доступен через tray. + +Не проверены реальные OAuth/каталог аккаунта Antigravity владельца и действующие ключи OpenRouter/NVIDIA. Учётные данные владельца не читались и не изменялись. A54 нельзя считать полностью принятой до этих проверок. + +## Артефакты + +- `local-live.png` — живой локальный сервер. +- `antigravity-fixture.png` — 14 синтетических моделей (не доказательство реального AG). +- `ollama-fixture.png` — пустой каталог и отказ соединения. +- `openrouter-invalid.png`, `openrouter-valid-fixture.png` — отказ и каталог HTTP стенда. +- `service-health.png` — состояние периодической службы. +- `tests/manual/a54_preview.py` — воспроизводимый изолированный стенд. +- `local-model-review.md`, `local-model-usage.json` — честный результат локальной делегации. + +Проверенные первичные описания API: [OpenRouter current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key), [NVIDIA LLM API](https://docs.api.nvidia.com/nim/reference/llm-apis). diff --git a/agents/reports/a54/antigravity-fixture.png b/agents/reports/a54/antigravity-fixture.png new file mode 100644 index 0000000..5c2411e Binary files /dev/null and b/agents/reports/a54/antigravity-fixture.png differ diff --git a/agents/reports/a54/deletion-benchmark.json b/agents/reports/a54/deletion-benchmark.json new file mode 100644 index 0000000..68a9d1d --- /dev/null +++ b/agents/reports/a54/deletion-benchmark.json @@ -0,0 +1,12 @@ +{ + "baseline_f0d06e4": { + "median_ms": 0.329, + "max_ms": 6.942, + "samples": 50 + }, + "a54": { + "median_ms": 0.324, + "max_ms": 63.205, + "samples": 50 + } +} diff --git a/agents/reports/a54/local-live.png b/agents/reports/a54/local-live.png new file mode 100644 index 0000000..4fbb3c5 Binary files /dev/null and b/agents/reports/a54/local-live.png differ diff --git a/agents/reports/a54/local-model-review.md b/agents/reports/a54/local-model-review.md new file mode 100644 index 0000000..6ba5579 --- /dev/null +++ b/agents/reports/a54/local-model-review.md @@ -0,0 +1,13 @@ +# A54 — локальная оркестрация + +Использованы HTTP chat completions на 8082 (Qwen3-4B-Instruct-2507) и 8081 (Qwen3-Coder-30B-A3B), последовательно, без передачи данных владельца. Изолированный Git worktree; ответы моделей не исполнялись автоматически. + +Циклы: probe-coder → probe-review → rework → review → rework; дополнительная передача сильному кодеру; preflight-coder → review → rework; Windows helper → review; отдельная генерация теста и ревью. Объёмы и время: `local-model-usage.json` (только сохранённые ответы; один потерянный ответ из-за ошибки оркестрационного скрипта не включён). + +## Итог аудита Codex + +Модели не довели критические части до приемлемого состояния самостоятельно. 4B повторно оставляла отказ при `enabled=False`, использовала несуществующий `threading.ThreadPoolExecutor`, неправильно читала JSON Ollama. 30B тоже предлагала фиктивные профили и успешную проверку без вызова inference. Эти версии отклонены; конечный код существенно переработан Codex. + +Ревью 30B полезно для поиска отдельных дефектов, но содержит ложные срабатывания. Например, оно объявляло нестабильным `setdefault` словаря блокировок под mutex и не признало отсутствие нужного импорта в предложенном тесте. Его `PASS` не принимался как достаточное основание. + +Исправленная схема на этой задаче: локальные кандидаты → статическая проверка Codex → исправления → детерминированные тесты → браузерное исполнение → Windows CI. Нельзя утверждать, что расходы Codex снизились: контрольного замера без делегации нет. diff --git a/agents/reports/a54/local-model-usage.json b/agents/reports/a54/local-model-usage.json new file mode 100644 index 0000000..3830569 --- /dev/null +++ b/agents/reports/a54/local-model-usage.json @@ -0,0 +1,198 @@ +[ + { + "step": "preflight-coder", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 13.08, + "usage": { + "completion_tokens": 1490, + "prompt_tokens": 394, + "total_tokens": 1884, + "prompt_tokens_details": { + "cached_tokens": 10 + } + }, + "finish_reason": "stop" + }, + { + "step": "preflight-review", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 5.61, + "usage": { + "completion_tokens": 333, + "prompt_tokens": 1536, + "total_tokens": 1869, + "prompt_tokens_details": { + "cached_tokens": 5 + } + }, + "finish_reason": "stop" + }, + { + "step": "preflight-rework", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 19.48, + "usage": { + "completion_tokens": 1705, + "prompt_tokens": 1849, + "total_tokens": 3554, + "prompt_tokens_details": { + "cached_tokens": 3 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-coder-1", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 16.74, + "usage": { + "completion_tokens": 1823, + "prompt_tokens": 1149, + "total_tokens": 2972, + "prompt_tokens_details": { + "cached_tokens": 0 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-review-1", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 5.89, + "usage": { + "completion_tokens": 603, + "prompt_tokens": 2059, + "total_tokens": 2662, + "prompt_tokens_details": { + "cached_tokens": 2058 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-review-2", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 7.71, + "usage": { + "completion_tokens": 511, + "prompt_tokens": 2115, + "total_tokens": 2626, + "prompt_tokens_details": { + "cached_tokens": 312 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-rework-1", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 19.38, + "usage": { + "completion_tokens": 1879, + "prompt_tokens": 2643, + "total_tokens": 4522, + "prompt_tokens_details": { + "cached_tokens": 10 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-rework-2", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 22.35, + "usage": { + "completion_tokens": 2163, + "prompt_tokens": 2607, + "total_tokens": 4770, + "prompt_tokens_details": { + "cached_tokens": 291 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-root-audit", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 6.82, + "usage": { + "completion_tokens": 490, + "prompt_tokens": 1488, + "total_tokens": 1978, + "prompt_tokens_details": { + "cached_tokens": 4 + } + }, + "finish_reason": "stop" + }, + { + "step": "probe-strong", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 24.7, + "usage": { + "completion_tokens": 2082, + "prompt_tokens": 2439, + "total_tokens": 4521, + "prompt_tokens_details": { + "cached_tokens": 5 + } + }, + "finish_reason": "stop" + }, + { + "step": "test-coder", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 2.67, + "usage": { + "completion_tokens": 302, + "prompt_tokens": 143, + "total_tokens": 445, + "prompt_tokens_details": { + "cached_tokens": 3 + } + }, + "finish_reason": "stop" + }, + { + "step": "test-review", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 4.22, + "usage": { + "completion_tokens": 385, + "prompt_tokens": 335, + "total_tokens": 720, + "prompt_tokens_details": { + "cached_tokens": 5 + } + }, + "finish_reason": "stop" + }, + { + "step": "windows-coder", + "model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf", + "seconds": 3.15, + "usage": { + "completion_tokens": 348, + "prompt_tokens": 173, + "total_tokens": 521, + "prompt_tokens_details": { + "cached_tokens": 3 + } + }, + "finish_reason": "stop" + }, + { + "step": "windows-review", + "model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + "seconds": 3.52, + "usage": { + "completion_tokens": 296, + "prompt_tokens": 378, + "total_tokens": 674, + "prompt_tokens_details": { + "cached_tokens": 3 + } + }, + "finish_reason": "stop" + } +] diff --git a/agents/reports/a54/ollama-fixture.png b/agents/reports/a54/ollama-fixture.png new file mode 100644 index 0000000..717891d Binary files /dev/null and b/agents/reports/a54/ollama-fixture.png differ diff --git a/agents/reports/a54/openrouter-invalid.png b/agents/reports/a54/openrouter-invalid.png new file mode 100644 index 0000000..a674107 Binary files /dev/null and b/agents/reports/a54/openrouter-invalid.png differ diff --git a/agents/reports/a54/openrouter-valid-fixture.png b/agents/reports/a54/openrouter-valid-fixture.png new file mode 100644 index 0000000..8969546 Binary files /dev/null and b/agents/reports/a54/openrouter-valid-fixture.png differ diff --git a/agents/reports/a54/service-health.png b/agents/reports/a54/service-health.png new file mode 100644 index 0000000..57106f6 Binary files /dev/null and b/agents/reports/a54/service-health.png differ diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 528f869..f70fa93 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -263,12 +263,40 @@ namespace HermesHubSetup return true; } + // Restrict cleanup to this installation. Never kill arbitrary Python/browser processes. + public static void StopOwnedRuntime(string home, bool includeLauncher) + { + string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''"); + string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " + + "$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " + + "$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " + + "while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " + + "function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " + + "if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " + + "$targets=@($all | Where-Object { " + + "($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" + + " -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" + + (includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") + + " }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " + + "if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }"; + ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script))); + info.UseShellExecute = false; + info.CreateNoWindow = true; + info.WindowStyle = ProcessWindowStyle.Hidden; + using (Process process = Process.Start(info)) + { + if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); } + if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено."); + } + } + public static int PerformInstall(string sourceRoot, Action progressCallback = null) { if (!IsHermesFound) return 10; try { + StopOwnedRuntime(HermesHome, true); if (progressCallback != null) progressCallback("Preparing installation directory...", 10); if (!Directory.Exists(TargetInstallDir)) { @@ -1074,12 +1102,14 @@ namespace HermesHubSetup Application.SetCompatibleTextRenderingDefault(false); bool isSilent = false; + bool restartAfterInstall = false; bool isUninstall = false; bool isRepair = false; bool purgeUserData = false; foreach (string a in args) { + if (a.Equals("/restart", StringComparison.OrdinalIgnoreCase)) restartAfterInstall = true; if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true; if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true; if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase) || a.Equals("/reinstall", StringComparison.OrdinalIgnoreCase)) isRepair = true; @@ -1149,6 +1179,11 @@ namespace HermesHubSetup } int code = SetupEngine.PerformInstall(sourceRoot); + if (code == 0 && restartAfterInstall) + { + string launcher = Path.Combine(SetupEngine.TargetInstallDir, "HermesHubWeb.exe"); + if (File.Exists(launcher)) Process.Start(launcher); + } Console.WriteLine("Silent install result: " + code); return code; } diff --git a/launcher/HermesHubWeb.cs b/launcher/HermesHubWeb.cs index 87164bf..63a1156 100644 --- a/launcher/HermesHubWeb.cs +++ b/launcher/HermesHubWeb.cs @@ -11,6 +11,8 @@ namespace HermesHub { public static class WebLauncher { + private static Mutex instanceMutex; + [STAThread] public static void Main(string[] args) { @@ -69,9 +71,17 @@ namespace HermesHub string targetUrl = string.Format("http://{0}:{1}/", host, port); string healthUrl = string.Format("http://{0}:{1}/api/health", host, port); + bool firstInstance; + instanceMutex = new Mutex(true, "Local\\HermesHubWeb", out firstInstance); + if (!firstInstance) { Process.Start(targetUrl); return; } + // Adopt no unknown server: stop only a verified process from our installation. + try { StopOwnedRuntime(hermesHome, false); } + catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } + // 2. Check if server is already running and healthy bool serverWasAlreadyRunning = IsServerHealthy(healthUrl); Process serverProcess = null; + StringBuilder serverLog = new StringBuilder(); if (!serverWasAlreadyRunning) { @@ -131,6 +141,14 @@ namespace HermesHub try { serverProcess = Process.Start(serverPsi); + DataReceivedEventHandler collect = delegate(object sender, DataReceivedEventArgs item) { + if (item.Data == null) return; + lock (serverLog) { serverLog.AppendLine(item.Data); if (serverLog.Length > 4000) serverLog.Remove(0, serverLog.Length - 4000); } + }; + serverProcess.ErrorDataReceived += collect; + serverProcess.OutputDataReceived += collect; + serverProcess.BeginErrorReadLine(); + serverProcess.BeginOutputReadLine(); } catch (Exception ex) { @@ -149,12 +167,8 @@ namespace HermesHub } if (serverProcess.HasExited) { - string why = ""; - try { why = serverProcess.StandardError.ReadToEnd(); } catch { } - if (string.IsNullOrEmpty(why)) - { - try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { } - } + string why; + lock (serverLog) { why = serverLog.ToString(); } if (why.Length > 1500) why = why.Substring(why.Length - 1500); string msg = "Веб-сервер Hermes Hub завершился с ошибкой."; if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim(); @@ -178,7 +192,7 @@ namespace HermesHub // 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback string browserPath = FindChromiumBrowser(); Process browserProc = null; - DateTime browserStartedAt = DateTime.UtcNow; + if (!string.IsNullOrEmpty(browserPath)) { @@ -199,7 +213,7 @@ namespace HermesHub try { browserProc = Process.Start(browserPsi); - browserStartedAt = DateTime.UtcNow; + } catch (Exception ex) { @@ -226,36 +240,113 @@ namespace HermesHub } } - // 5. Server Lifecycle: - // If the server was started by this launcher session and browser is tracked, - // wait for browser window to close, then gracefully terminate server process. - if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null) + Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc)); + instanceMutex.ReleaseMutex(); + } + + // Restrict cleanup to this installation. Never kill arbitrary Python/browser processes. + public static void StopOwnedRuntime(string home, bool includeLauncher) + { + string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''"); + string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " + + "$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " + + "$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " + + "while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " + + "function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " + + "if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " + + "$targets=@($all | Where-Object { " + + "($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" + + " -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" + + (includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") + + " }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " + + "if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }"; + ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script))); + info.UseShellExecute = false; + info.CreateNoWindow = true; + info.WindowStyle = ProcessWindowStyle.Hidden; + using (Process process = Process.Start(info)) { - try - { - browserProc.WaitForExit(); - } - catch { } - - // Подстраховка: если процесс браузера завершился почти сразу, - // это почти наверняка передача окна другому экземпляру, а не - // закрытие пользователем. Убивать сервер в этом случае нельзя. - if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5)) - { - return; - } - - try - { - if (!serverProcess.HasExited) - { - serverProcess.Kill(); - } - } - catch { } + if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); } + if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено."); } } + private sealed class HubContext : ApplicationContext + { + private readonly NotifyIcon tray; + private readonly System.Windows.Forms.Timer timer; + private readonly string home, url, browserPath; + private Process browser; + private bool watching, hadWindow, closing; + + public HubContext(string homePath, string targetUrl, string browserExe, Process browserProcess) + { + home = homePath; url = targetUrl; browserPath = browserExe; browser = browserProcess; + watching = browser != null; + tray = new NotifyIcon(); + tray.Icon = System.Drawing.SystemIcons.Application; + tray.Text = "Hermes Hub — работает в фоне"; + ContextMenuStrip menu = new ContextMenuStrip(); + menu.Items.Add("Открыть", null, delegate { Open(); }); + menu.Items.Add("Выход", null, delegate { ExitCompletely(); }); + tray.ContextMenuStrip = menu; + tray.DoubleClick += delegate { Open(); }; + tray.Visible = true; + timer = new System.Windows.Forms.Timer(); timer.Interval = 500; + timer.Tick += delegate { WatchWindow(); }; timer.Start(); + } + + private void WatchWindow() + { + if (!watching || closing || browser == null) return; + bool closed; + try { + browser.Refresh(); + if (!browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) hadWindow = true; + closed = browser.HasExited || (hadWindow && browser.MainWindowHandle == IntPtr.Zero); + } catch { closed = true; } + if (!closed) return; + watching = false; + DialogResult answer = MessageBox.Show("Закрыть Hermes Hub полностью?\n\nДа — остановить сервер и фоновые опросы.\nНет — оставить в фоне (значок в области уведомлений).", "Hermes Hub", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (answer == DialogResult.Yes) ExitCompletely(); + } + + private void Open() + { + if (closing) return; + try { + if (browser != null && !browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) { + ShowWindow(browser.MainWindowHandle, 9); SetForegroundWindow(browser.MainWindowHandle); return; + } + if (string.IsNullOrEmpty(browserPath)) { Process.Start(url); return; } + ProcessStartInfo info = new ProcessStartInfo(browserPath, + "--app=\"" + url + "\" --window-size=1400,900 --user-data-dir=\"" + Path.Combine(home, "web_browser_profile") + "\" --no-first-run --no-default-browser-check"); + info.UseShellExecute = false; + browser = Process.Start(info); watching = true; hadWindow = false; + } catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub"); } + } + + private void ExitCompletely() + { + if (closing) return; + closing = true; timer.Stop(); + try { + if (browser != null && !browser.HasExited) { + ProcessStartInfo kill = new ProcessStartInfo("taskkill.exe", "/PID " + browser.Id + " /T /F"); + kill.UseShellExecute = false; kill.CreateNoWindow = true; + using (Process process = Process.Start(kill)) { process.WaitForExit(5000); } + } + StopOwnedRuntime(home, false); + tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread(); + } catch (Exception ex) { + closing = false; timer.Start(); + MessageBox.Show("Не удалось завершить всё: " + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr handle); + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr handle, int command); + } + public static bool IsServerHealthy(string url) { try diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0e1de1e..4faf8bc 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) { Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null +# Stop only processes owned by this installation before replacing files. +# Preserve our own ancestor branch: an updater may have launched this installer. +$hubProcesses = @(Get-CimInstance Win32_Process) +$hubProtected = @($PID) +$hubCursor = $PID +while ($hubCursor) { + $hubNode = $hubProcesses | Where-Object ProcessId -eq $hubCursor | Select-Object -First 1 + if (-not $hubNode) { break } + $hubCursor = $hubNode.ParentProcessId + if ($hubCursor -in $hubProtected) { break } + $hubProtected += $hubCursor +} +function Stop-HubBranch([int]$ProcessId) { + foreach ($child in @($hubProcesses | Where-Object ParentProcessId -eq $ProcessId)) { + if ($child.ProcessId -notin $hubProtected) { Stop-HubBranch $child.ProcessId } + } + if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) { + Stop-Process -Id $ProcessId -Force -ErrorAction Stop + } +} +$hubPythonPaths = @($HermesPython, (Join-Path $HermesHome 'hermes-agent\venv\Scripts\pythonw.exe')) +$hubLauncherPaths = @((Join-Path $HermesHome 'HermesHubWeb.exe'), (Join-Path $TargetDir 'HermesHubWeb.exe')) +$hubBrowserPattern = '--user-data-dir="?' + [regex]::Escape((Join-Path $HermesHome 'web_browser_profile')) + '"?(?:\s|$)' +foreach ($hubProcess in $hubProcesses) { + if (($hubProcess.ExecutablePath -in $hubPythonPaths -and $hubProcess.CommandLine -match 'hermes_hub_web_entry\.py|antigravity_provider\.router\.web') -or + ($hubProcess.ExecutablePath -in $hubLauncherPaths) -or + ($hubProcess.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $hubProcess.CommandLine -match $hubBrowserPattern)) { + Stop-HubBranch $hubProcess.ProcessId + if (Get-Process -Id $hubProcess.ProcessId -ErrorAction SilentlyContinue) { throw 'Old Hermes Hub process survived. Installation cancelled.' } + } +} + # 4. Copy Application Files to TargetDir $RepoRoot = Split-Path -Parent $PSScriptRoot Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index da1d12e..afda537 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -113,7 +113,7 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]: Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model. """ global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP - if _AGY_MODEL_CACHE is not None: + if profile_id is None and _AGY_MODEL_CACHE: return dict(_AGY_MODEL_CACHE) exe = get_agy_exe() @@ -166,23 +166,13 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]: ) raw = result.stdout.strip() if not raw or result.returncode != 0: - logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode) - if _AGY_MODEL_CACHE is None: - _AGY_MODEL_CACHE = {} - _AGY_EFFORT_MAP = {} - return dict(_AGY_MODEL_CACHE) - except subprocess.TimeoutExpired: - logger.warning("discover_models: agy models timed out") - if _AGY_MODEL_CACHE is None: - _AGY_MODEL_CACHE = {} - _AGY_EFFORT_MAP = {} - return dict(_AGY_MODEL_CACHE) - except Exception as exc: - logger.warning("discover_models failed: %s", exc) - if _AGY_MODEL_CACHE is None: - _AGY_MODEL_CACHE = {} - _AGY_EFFORT_MAP = {} - return dict(_AGY_MODEL_CACHE) + if profile_id: + raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен") + return dict(_AGY_MODEL_CACHE or {}) + except Exception: + if profile_id: + raise + return dict(_AGY_MODEL_CACHE or {}) models: dict[str, str] = {} effort_map: dict[str, set[str]] = {} @@ -223,52 +213,6 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]: return dict(models) -def launch_native_agy_login(profile_id: str) -> subprocess.Popen: - """Launch agy CLI in a visible interactive terminal window with isolated profile environment. - - A22 Requirement: Native login executed by agy itself within the target profile's isolated directory. - Zero interception, zero credential logging. - """ - from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir - - profile_dir = get_profile_env_dir(profile_id) - profile_dir.mkdir(parents=True, exist_ok=True) - gemini_dir = profile_dir / ".gemini" - gemini_dir.mkdir(parents=True, exist_ok=True) - - exe = get_agy_exe() - env = build_safe_subprocess_env( - overrides={ - "USERPROFILE": str(profile_dir), - "HOME": str(profile_dir), - "HOMEPATH": str(profile_dir), - "HOMEDRIVE": str(profile_dir)[:2] if str(profile_dir)[1:2] == ":" else "", - } - ) - - if os.name == "nt": - # Launch visible console window on Windows - return subprocess.Popen( - [exe], - env=env, - creationflags=subprocess.CREATE_NEW_CONSOLE, - ) - else: - # Cross-platform fallback (Linux/macOS) - import shutil - - terminals = [ - ["x-terminal-emulator", "-e", exe], - ["gnome-terminal", "--", exe], - ["xterm", "-e", exe], - ["konsole", "-e", exe], - ] - for term_cmd in terminals: - if shutil.which(term_cmd[0]): - return subprocess.Popen(term_cmd, env=env) - return subprocess.Popen([exe], env=env) - - def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]: """Check if agy native authentication has completed in profile's directory. diff --git a/src/antigravity_provider/router/account_probe_service.py b/src/antigravity_provider/router/account_probe_service.py index 54e292b..25e84b6 100644 --- a/src/antigravity_provider/router/account_probe_service.py +++ b/src/antigravity_provider/router/account_probe_service.py @@ -14,6 +14,11 @@ class AccountProbeService: def __init__(self) -> None: self.enabled = False self._next_check = 0.0 + self.last_tick = None + self.error = None + self._profile_locks = {} + self._closed = False + self._cloud_next = 0.0 self._states: dict[str, dict[str, Any]] = {} self._lock = threading.Lock() self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe") @@ -29,66 +34,134 @@ class AccountProbeService: with self._lock: return dict(self._states.get(profile_id, {"state": "never_checked"})) - def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool: - if not self.enabled: - return False + def _profile_lock(self, profile_id: str): + with self._lock: + return self._profile_locks.setdefault(profile_id, threading.Lock()) + + def _mark_checking(self, provider, profile_id): with self._lock: - current = self._states.get(profile_id, {}) - if current.get("state") == "checking": - return False - if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30: - return False self._states[profile_id] = { - **current, "state": "checking", "provider": provider, - "started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты", + **self._states.get(profile_id, {}), "state": "checking", + "provider": provider, "started_at": time.time(), + "message": "Идёт запрос к провайдеру", } - self._pool.submit(self._run, provider, profile_id) + + def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool: + if not self.enabled or self._closed: + return False + lock = self._profile_lock(profile_id) + if not lock.acquire(blocking=False): + return False + current = self.state(profile_id) + if not force and time.time() - current.get("checked_at", 0) < 30: + lock.release() + return False + self._mark_checking(provider, profile_id) + try: + future = self._pool.submit(self._run, provider, profile_id) + def cancelled(done): + if done.cancelled(): + with self._lock: + self._states[profile_id] = {"state": "failed", "message": "Проверка отменена при завершении сервера"} + lock.release() + future.add_done_callback(cancelled) + except Exception: + with self._lock: + self._states[profile_id] = current + lock.release() + raise return True + def check_now(self, provider: str, profile_id: str, models_only: bool = False) -> dict: + # Periodic scheduling can be disabled without disabling a manual request. + if self._closed: + return {"ok": False, "message": "Сервер завершает работу"} + lock = self._profile_lock(profile_id) + if not lock.acquire(timeout=90): + return {"ok": False, "message": "Проверка этого аккаунта ещё выполняется; повторите позже"} + try: + if self._closed: + return {"ok": False, "message": "Сервер завершает работу"} + self._mark_checking(provider, profile_id) + return self._probe(provider, profile_id, models_only) + finally: + lock.release() + + def record_validation(self, provider: str, profile_id: str, result: dict) -> None: + with self._lock: + self._states[profile_id] = { + "state": "working", "provider": provider, "checked_at": time.time(), + "message": result["message"], "models": result["data"]["models"], + "check_kind": "credentials_and_catalog", + } + + def status(self) -> dict: + return {"enabled": self.enabled, "last_tick": self.last_tick, "error": self.error} + def tick(self, now: Optional[float] = None) -> int: from .settings_service import get_hub_settings now = time.monotonic() if now is None else now if not self.enabled or now < self._next_check: return 0 - self._next_check = now + get_hub_settings()["account_check_interval_seconds"] - return self.schedule_all(force=True) + try: + count = self.schedule_all(force=True) + self._next_check = now + get_hub_settings()["account_check_interval_seconds"] + self.last_tick, self.error = time.time(), None + return count + except Exception as exc: + self.error = str(exc).strip() or type(exc).__name__ + return 0 + + def shutdown(self) -> None: + self.enabled, self._closed = False, True + self._pool.shutdown(wait=False, cancel_futures=True) def schedule_all(self, *, force: bool = False) -> int: from .profile_manager import ProfileAuthManager from .router_config import load_router_config count = 0 + has_ollama = False for pid, pcfg in load_router_config().profiles.items(): if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"): count += int(self.schedule(pcfg.provider, pid, force=force)) + has_ollama |= pcfg.provider == "ollama" + if self.enabled and has_ollama and time.monotonic() >= self._cloud_next: + from .model_discovery_service import ModelDiscoveryService + self._cloud_next = time.monotonic() + 3600 + self._pool.submit(ModelDiscoveryService.get().discover_ollama_cloud) return count def _run(self, provider: str, profile_id: str) -> None: + try: + self._probe(provider, profile_id) + finally: + self._profile_lock(profile_id).release() + + def _probe(self, provider: str, profile_id: str, models_only: bool = False) -> dict: from .action_handler import do_test_profile from .model_discovery_service import ModelDiscoveryService + models, meta = None, {} try: - from .state_store import HubStateStore - HubStateStore.get().refresh(force_scan=True) - models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id) - HubStateStore.get().refresh(force_scan=True) - if provider == "ollama": - cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog") - if cloud.get("is_stale"): - ModelDiscoveryService.get().discover_ollama_cloud() - result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models) - meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id) - success = bool(result.get("success")) - message = result.get("response") or result.get("error") or "Проверка завершена без пояснения" + discovery = ModelDiscoveryService.get() + models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id) + meta = discovery.get_models_with_metadata(provider, profile_id) + if models is None and meta.get("error"): + success, message = False, meta["error"] + elif models_only: + success = models is not None and not meta.get("error") + message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей") + else: + result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models) + success = bool(result.get("success")) + message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки" state = "working" if success else "failed" except Exception as exc: - models, meta, state, message = None, {}, "failed", str(exc) + success, state, message = False, "failed", str(exc).strip() or type(exc).__name__ + record = { + "state": state, "provider": provider, "checked_at": time.time(), + "message": message, "models": models, "model_error": meta.get("error"), + "models_discovered_at": meta.get("discovered_at"), "models_only": models_only, + } with self._lock: - self._states[profile_id] = { - "state": state, "provider": provider, "checked_at": time.time(), - "message": message, "models": models, "model_error": meta.get("error"), - "models_discovered_at": meta.get("discovered_at"), - } - try: - from .state_store import HubStateStore - HubStateStore.get().refresh(force_scan=True) - except Exception: - pass + self._states[profile_id] = record + return {"ok": success, "message": message, "data": record} diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 712edbc..5558e0d 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -18,6 +18,8 @@ from antigravity_provider import paths from antigravity_provider.router.adapters import get_adapter logger = logging.getLogger('hermes.router.actions') +_test_locks_guard = threading.Lock() +_test_locks: dict[str, Any] = {} def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]: ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id) @@ -55,7 +57,10 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED': return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'} - model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0] + candidates = discovered_models if discovered_models is not None else pcfg.preferred_models + if not candidates: + return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'} + model = next((model for model in pcfg.preferred_models if model in candidates), candidates[0]) t0 = time.time() try: auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id) @@ -73,14 +78,25 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco result_container = [] error_container = [] + with _test_locks_guard: + invoke_lock = _test_locks.setdefault(profile_id, threading.Lock()) + if not invoke_lock.acquire(blocking=False): + return {'success': False, 'error': 'Предыдущий запрос этого аккаунта ещё не завершился'} + def _call_invoke(): try: result_container.append(adapter.invoke(pcfg, req)) except Exception as e: error_container.append(e) + finally: + invoke_lock.release() t = threading.Thread(target=_call_invoke, daemon=True) - t.start() + try: + t.start() + except Exception: + invoke_lock.release() + raise t.join(timeout=timeout) el = round(time.time() - t0, 2) @@ -110,7 +126,7 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco } except Exception as e: EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error') - return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)} + return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e).strip() or type(e).__name__} def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]: # Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали @@ -125,6 +141,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") if auth_p.is_file(): try: auth_p.unlink() + from .state_store import HubStateStore + HubStateStore.get().apply_delta_account_removed(provider, profile_id) EventLogService.get().log( 'account', f'Учетные данные для {profile_id} удалены.', @@ -281,11 +299,8 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) -> updated.roles[role_id].default_model = model if save_router_config(updated): - try: - from antigravity_provider.router.state_store import HubStateStore - HubStateStore.get().refresh(force_scan=True) - except Exception: - pass + from .state_store import HubStateStore + HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models) EventLogService.get().log( "model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info" ) @@ -341,15 +356,19 @@ def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool # Функция объявлена на уровне модуля намеренно: как вложенная она была видна # не всем точкам завершения входа, и device-flow получал NameError внутри # обработки успеха. -def _rescan_after_auth() -> None: - try: - from antigravity_provider.router.state_store import HubStateStore - - HubStateStore.get().refresh(force_scan=True) - from .account_probe_service import AccountProbeService - AccountProbeService.get().schedule_all() - except Exception as exc: # пересбор не должен ронять сам вход - logger.warning("Не удалось пересобрать снапшот после входа: %s", exc) +def _rescan_after_auth(provider=None, profile_id=None) -> None: + def refresh(): + try: + from .state_store import HubStateStore + from .account_probe_service import AccountProbeService + if provider and profile_id: + HubStateStore.get().apply_delta_account_added(provider, profile_id) + else: + HubStateStore.get().refresh(force_scan=True) + AccountProbeService.get().schedule_all() + except Exception as exc: + logger.warning("Не удалось обновить состояние после входа: %s", exc) + threading.Thread(target=refresh, name="auth-state-refresh", daemon=True).start() def generate_quotas_export(format: str = "json") -> Any: @@ -686,6 +705,10 @@ class ActionExecutor: return {'ok': False, 'message': reason, 'data': {'status': status}} return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}} + if action == '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 '') + # Подключение аккаунта: сохранение профиля и учетных данных (P0-1) if action == 'add_account': prov_norm = (prov or data.get('provider') or '').strip().lower() @@ -712,6 +735,17 @@ class ActionExecutor: token = (data.get('token') or data.get('api_key') or '').strip() slot = data.get('profile_id') + if slot: + valid, reason = AutoAssigner.validate_slot(prov_norm, slot) + if not valid: + return {'ok': False, 'message': reason} + validation = None + if token or prov_norm in ('local', 'vllm', 'ollama'): + from .connection_preflight import validate_connection + validation = validate_connection(prov_norm, token, base_url, data.get('preferred_model') or '') + if not validation['ok']: + return validation + base_url = validation['data']['base_url'] slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1' status = ProfileAuthManager.get_profile_status(prov_norm, slot) @@ -789,11 +823,21 @@ class ActionExecutor: # авторизованного аккаунта не существует: перевод аккаунта в # другую роль падал с ошибкой, хотя ключ вводить не требуется. AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False) - _rescan_after_auth() + if validation: + from .model_discovery_service import ModelDiscoveryService + ModelDiscoveryService.get().remember_models(prov_norm, slot, validation['data']['models']) + if data.get('preferred_model'): + ok, message = do_set_model(slot, data['preferred_model']) + if not ok: + return {'ok': False, 'message': message} + _rescan_after_auth(prov_norm, slot) from antigravity_provider.router.account_probe_service import AccountProbeService - AccountProbeService.get().schedule(prov_norm, slot, force=True) - check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена' - return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}} + if validation: + AccountProbeService.get().record_validation(prov_norm, slot, validation) + return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}} + result = AccountProbeService.get().check_now(prov_norm, slot) + result.setdefault('data', {})['profile_id'] = slot + return result except Exception as e: return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'} @@ -942,6 +986,29 @@ class ActionExecutor: res = do_test_profile(prov, pid) return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res} + elif action == 'clear_accounts': + from .profile_manager import get_profile_auth_path + protected_root = (paths.get_hermes_home() / 'agy_profiles').resolve() + targets, protected = [], [] + for profile_id, profile in load_router_config().profiles.items(): + auth_path = get_profile_auth_path(profile.provider, profile_id) + if profile.provider in ('antigravity', 'google-antigravity', 'agy') or auth_path.is_symlink() or auth_path.resolve().is_relative_to(protected_root): + protected.append(profile_id) + elif auth_path.is_file(): + targets.append({'provider': profile.provider, 'profile_id': profile_id}) + preview = {'targets': targets, 'protected': protected} + if not data.get('confirmed'): + return {'ok': True, 'message': f'Будут удалены ключи {len(targets)} аккаунтов. Antigravity исключён из очистки.', 'data': preview} + # Require the exact displayed list. A newly added account is never silently deleted. + if data.get('targets') != targets: + return {'ok': False, 'message': 'Список аккаунтов изменился. Повторите предварительный просмотр.', 'data': preview} + errors = [] + for target in targets: + ok, message = do_delete_credentials(target['provider'], target['profile_id'], actor=actor) + if not ok: + errors.append(message) + return {'ok': not errors, 'message': '; '.join(errors) if errors else f'Удалены ключи {len(targets)} аккаунтов. Antigravity сохранён.', 'data': preview} + elif action == 'delete_credentials': dry_run = bool(data.get('dry_run', False)) confirmed = bool(data.get('confirmed', True)) @@ -1042,13 +1109,10 @@ class ActionExecutor: elif action == 'check_account': from antigravity_provider.router.account_probe_service import AccountProbeService - if not AccountProbeService.get().enabled: - return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."} valid, reason = AutoAssigner.validate_slot(prov, pid) if not valid: - return {'ok': False, 'message': reason} - started = AccountProbeService.get().schedule(prov, pid, force=True) - return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'} + return {'ok': False, 'message': reason or 'Неверный профиль'} + return AccountProbeService.get().check_now(prov, pid) elif action == 'check_all_accounts': from antigravity_provider.router.account_probe_service import AccountProbeService @@ -1074,6 +1138,12 @@ class ActionExecutor: return {'ok': True, 'message': 'Успешно'} elif action == 'refresh_models': + if pid: + from .account_probe_service import AccountProbeService + valid, reason = AutoAssigner.validate_slot(prov, pid) + if not valid: + return {'ok': False, 'message': reason or 'Неверный профиль'} + return AccountProbeService.get().check_now(prov, pid, models_only=True) from antigravity_provider.router.model_discovery_service import ModelDiscoveryService service = ModelDiscoveryService.get() if prov: diff --git a/src/antigravity_provider/router/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py new file mode 100644 index 0000000..b19833f --- /dev/null +++ b/src/antigravity_provider/router/connection_preflight.py @@ -0,0 +1,93 @@ +"""Validate supplied credentials before creating any profile or writing auth files.""" +import json +import urllib.error +import urllib.request +from urllib.parse import urlsplit + +DEFAULT_URLS = { + "openrouter": "https://openrouter.ai/api/v1", + "nvidia": "https://integrate.api.nvidia.com/v1", + "local": "http://127.0.0.1:8081/v1", "vllm": "http://127.0.0.1:8081/v1", + "ollama": "http://127.0.0.1:11434", "claude": "https://api.anthropic.com/v1", + "opencode-go": "https://opencode.ai/zen/go/v1", "grok": "https://api.x.ai/v1", + "openai-codex": "https://api.openai.com/v1", +} + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError("Перенаправление API запрещено: укажите конечный URL сервера") + + +def validate_connection(provider, token="", base_url="", preferred_model=""): + provider = {"nvidia-nim": "nvidia", "local-llm": "local", "llama.cpp": "local"}.get(provider, provider) + try: + base_url = (base_url or DEFAULT_URLS.get(provider, "")).rstrip("/") + parsed = urlsplit(base_url) + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment: + raise ValueError("Укажите HTTP(S) URL сервера без пароля, параметров и фрагмента") + if provider not in DEFAULT_URLS: + raise ValueError("Для этого провайдера используйте вход через авторизацию") + if provider not in ("local", "vllm", "ollama") and not token: + raise ValueError("Не указан API-ключ") + headers = {"Content-Type": "application/json"} + if provider == "claude": + headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"}) + elif token: + headers["Authorization"] = "Bearer " + token + opener = urllib.request.build_opener(_NoRedirect()) + + def request(path, body=None): + req = urllib.request.Request(base_url + path, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + with opener.open(req, timeout=20) as response: + payload = json.load(response) + if not isinstance(payload, dict) or payload.get("error"): + raise ValueError("Провайдер вернул ошибку или неверный JSON") + return payload + + if provider == "openrouter": + # The catalog is public: its HTTP 200 is not proof of a valid key. + request("/key") + if provider == "ollama": + base_url = base_url.removesuffix("/v1") + payload, field, key = request("/api/tags"), "models", "name" + else: + payload, field, key = request("/models"), "data", "id" + entries = payload.get(field) + if not isinstance(entries, list): + raise ValueError("Провайдер не вернул список моделей") + models = sorted({m[key] for m in entries if isinstance(m, dict) and isinstance(m.get(key), str) and m[key]}) + if provider == "nvidia": + if not models: + raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно") + if preferred_model and preferred_model not in models: + raise ValueError("Выбранной модели нет в каталоге NVIDIA") + # NVIDIA also exposes a public catalog. Validate with a real request. + 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: + raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста") + result = request("/chat/completions", {"model": preferred_model or chat_models[0], + "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}) + if not isinstance(result.get("choices"), list) or not result["choices"]: + raise ValueError("NVIDIA не вернула результат тестового запроса") + message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет" + return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}} + except Exception as exc: + if isinstance(exc, urllib.error.HTTPError): + reason = exc.reason or 'провайдер отклонил запрос' + try: + body = json.loads(exc.read(4096).decode('utf-8', errors='replace')) + detail = body.get('error') or body.get('detail') + if isinstance(detail, dict): + detail = detail.get('message') + if isinstance(detail, str) and detail.strip(): + reason = detail[:500] + except Exception: + pass + message = f"HTTP {exc.code}: {reason}" + else: + message = str(exc).strip() or type(exc).__name__ + if token: + message = message.replace(token, "[скрыто]") + return {"ok": False, "message": message, "data": {"models": []}} diff --git a/src/antigravity_provider/router/model_discovery_service.py b/src/antigravity_provider/router/model_discovery_service.py index b229c19..37954f4 100644 --- a/src/antigravity_provider/router/model_discovery_service.py +++ b/src/antigravity_provider/router/model_discovery_service.py @@ -76,6 +76,13 @@ class ModelDiscoveryService: # NON-BLOCKING READ API # ───────────────────────────────────────────────────────────── + def remember_models(self, provider: str, profile_id: str, models: list[str]) -> None: + with self._cache_lock: + self._cache[f"{provider.lower()}:{profile_id}"] = { + "models": list(models), "discovered_at": time.time(), "error": None, + } + self._save_cache_to_disk() + def get_models(self, provider: str) -> Optional[List[str]]: """Return cached models for provider immediately, or None if undiscovered.""" meta = self.get_models_with_metadata(provider) @@ -203,7 +210,7 @@ class ModelDiscoveryService: result_holder[0] = models error_holder[0] = err_msg except Exception as exc: - error_holder[0] = str(exc) + error_holder[0] = str(exc).strip() or type(exc).__name__ worker = threading.Thread(target=_do_probe, daemon=True) worker.start() @@ -412,7 +419,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("Codex model query HTTP error on %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("Codex model query failed on %s: %s", pid, exc) return None, last_err or "Отсутствуют учетные данные для OpenAI Codex" @@ -448,7 +455,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("OpenCode model query failed on %s: %s", pid, exc) return None, last_err or "Отсутствуют учетные данные для OpenCode Go" @@ -484,7 +491,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("Grok model discovery failed for %s: %s", pid, exc) return None, last_err or "Отсутствуют учетные данные для Grok" @@ -523,7 +530,7 @@ class ModelDiscoveryService: except urllib.error.HTTPError as http_err: last_err = self._extract_http_error(http_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ return None, last_err or "Отсутствуют учетные данные для Claude" elif prov in ("openrouter",): @@ -574,7 +581,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc) return None, last_err or "Отсутствуют учетные данные для OpenRouter" @@ -613,7 +620,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc) return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM" @@ -655,7 +662,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc) # 2. Try OpenAI-compatible endpoint /v1/models @@ -675,7 +682,7 @@ class ModelDiscoveryService: last_err = self._extract_http_error(http_err) logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc) return None, last_err or "Не удалось подключиться к серверу Ollama" @@ -708,13 +715,12 @@ class ModelDiscoveryService: for m in items if m ] - if models: - return sorted(set(models)), None + return sorted(set(models)), None except urllib.error.HTTPError as http_err: last_err = self._extract_http_error(http_err) logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err) except Exception as exc: - last_err = str(exc) + last_err = str(exc).strip() or type(exc).__name__ logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc) return None, last_err or "Не удалось подключиться к локальному серверу LLM" diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index 704c054..3eb4f97 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -75,6 +75,7 @@ class HubStateStore: def __init__(self) -> None: self._lock = threading.RLock() + self._removed_accounts: set[str] = set() self._generation: int = 0 self._current_snapshot: Optional[HubSnapshot] = None self._pending_refreshes: Dict[str, float] = {} @@ -138,6 +139,9 @@ class HubStateStore: # requests may complete while this build is running. uh_service = UnifiedHealthService.get() profiles_by_prov = uh_service.scan_all(force=force_scan) + with self._lock: + removed = set(self._removed_accounts) + profiles_by_prov = {provider: [p for p in profiles if p.profile_id not in removed] for provider, profiles in profiles_by_prov.items()} all_profs = { profile.profile_id: profile for profiles in profiles_by_prov.values() @@ -386,6 +390,8 @@ class HubStateStore: profile: ProfileViewModel | str, profile_id: Optional[str] = None, ) -> None: + with self._lock: + self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id) if isinstance(profile, str): provider = profile if profile_id is None: @@ -410,8 +416,16 @@ class HubStateStore: }, ) + def apply_delta_profile_preferences(self, profile_id: str, models: list[str]) -> None: + # A configuration change does not require quota/identity network requests. + with self._lock: + profile = self._current_snapshot.get_profile(profile_id) if self._current_snapshot else None + if profile: + self._apply_profile_delta(replace(profile, preferred_models=list(models))) + def apply_delta_account_removed(self, provider: str, profile_id: str) -> None: with self._lock: + self._removed_accounts.add(profile_id) current = self._current_snapshot or self._build_empty_snapshot() all_profiles = dict(current.all_profiles) all_profiles.pop(profile_id, None) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 4863bdb..19fb431 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -539,7 +539,7 @@ class UnifiedHealthService: model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid) if prov == "ollama": model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog") - if is_authenticated and pcfg.enabled: + if is_authenticated and pcfg.enabled and not check.get("models_only"): if check.get("state") == "checking": health_state, health_lbl = "checking", "Проверяется…" elif check.get("state") == "failed": diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 09e8c72..88f2e93 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -101,7 +101,10 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool: @app.get("/api/health") def health_check(): + from ..account_probe_service import AccountProbeService return { + "pid": os.getpid(), + "account_probe": AccountProbeService.get().status(), "ok": True, "version": __version__, "commit": get_installed_commit(), @@ -194,6 +197,28 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): raise HTTPException(status_code=503, detail="Snapshot not ready") snap_dict = dataclasses.asdict(snapshot) + from ..account_probe_service import AccountProbeService + from ..model_discovery_service import ModelDiscoveryService + probe, discovery = AccountProbeService.get(), ModelDiscoveryService.get() + snap_dict["account_probe"] = probe.status() + for profile in snap_dict.get("all_profiles", {}).values(): + pid, provider = profile["profile_id"], profile["provider"] + check = probe.state(pid) + profile["connection_check"] = check + if profile.get("auth_state") == "AUTHENTICATED" and profile.get("enabled", True) and not check.get("models_only"): + if check.get("state") == "working": + profile["health_state"], profile["health_label_ru"] = "healthy", "Проверен: работает" + elif check.get("state") == "failed": + profile["health_state"], profile["health_label_ru"] = "unhealthy", "Проверен: не работает — " + check["message"] + elif check.get("state") == "checking": + profile["health_state"], profile["health_label_ru"] = "checking", "Проверяется…" + profile["model_discovery"] = discovery.get_models_with_metadata(provider, pid) + if provider == "ollama": + profile["model_discovery"]["cloud"] = discovery.get_models_with_metadata("ollama-cloud-catalog") + snap_dict["profiles_by_provider"] = { + provider: [snap_dict["all_profiles"].get(p["profile_id"], p) for p in profiles] + for provider, profiles in snap_dict.get("profiles_by_provider", {}).items() + } snap_dict = sanitize_snapshot(snap_dict) server_host = _web_settings().get("web_api_host", "127.0.0.1") @@ -226,13 +251,17 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to threading.Thread(target=func, name=name, daemon=True).start() actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web") - result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor) + try: + result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor) + except Exception as exc: + result = {"ok": False, "message": f"Действие {action} завершилось ошибкой {type(exc).__name__}"} + if result.get("unknown"): raise HTTPException(status_code=404, detail="Неизвестное действие") return { "ok": result.get("ok", False), - "message": result.get("message", ""), + "message": result.get("message") or ("Действие выполнено" if result.get("ok") else f"Действие {action} не выполнено: обработчик не сообщил причину"), "data": result.get("data", {}) } @@ -488,6 +517,7 @@ if _STATIC_DIR.is_dir(): # ───────────────────────────────────────────────────────────── _SNAPSHOT_REFRESH_SEC = 30 +_background_stop = threading.Event() def _background_refresh_loop() -> None: @@ -509,17 +539,18 @@ def _background_refresh_loop() -> None: except Exception as exc: logger.debug("Initial background update check skipped: %s", exc) - while True: + while not _background_stop.is_set(): try: AccountProbeService.get().tick() HubStateStore.get().refresh(force_scan=False) except Exception as exc: logger.warning("Snapshot refresh failed: %s", exc) - time.sleep(_SNAPSHOT_REFRESH_SEC) + _background_stop.wait(_SNAPSHOT_REFRESH_SEC) @app.on_event("startup") def _start_background_refresh() -> None: + _background_stop.clear() # В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём # старт сервера нельзя. threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start() @@ -530,3 +561,12 @@ def _start_background_refresh() -> None: AccountQuotaService.get().start_background_scheduler() except Exception as exc: logger.warning("Could not start quota scheduler: %s", exc) + + +@app.on_event("shutdown") +def _stop_background_refresh() -> None: + from ..account_probe_service import AccountProbeService + from ..quota_collector import AccountQuotaService + _background_stop.set() + AccountProbeService.get().shutdown() + AccountQuotaService.get().stop_background_scheduler() diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 08fcfd7..233ddcf 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -702,7 +702,7 @@ function renderAccountsView() { const profilesByProv = currentSnapshot.profiles_by_provider || {}; let totalProfiles = 0; let visibleProfiles = 0; - let html = '
'; + let html = '
'; for (const [providerId, profiles] of Object.entries(profilesByProv)) { if (providerFilter !== 'all' && providerFilter !== providerId) continue; @@ -846,7 +846,7 @@ function renderAccountCard(profile) { -
${(profile.preferred_models || []).map(modelBrandLabel).join('')}
+
Предпочитаемые:${(profile.preferred_models || []).map(modelBrandLabel).join('')}
${renderAccountCheck(profile)} ${quotaGridHtml} @@ -862,8 +862,9 @@ function renderAccountCheck(profile) { const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен'; return `
${checking ? `

${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.

` : ''} +

${escapeHtml(check.message || "Подключение ещё не проверялось")}

${escapeHtml(modelStatus)}

- + ${profile.provider === 'ollama' ? `

Выше — модели указанного сервера Ollama.

Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}

Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.

` : ''}
`; @@ -1516,6 +1517,10 @@ function renderHealthView() { renderHostResources(resContainer, currentSnapshot.metrics?.host || {}); } + if (banner) { + const probe = currentSnapshot.account_probe || {}; + banner.insertAdjacentHTML('beforeend', `

Автопроверка: ${probe.enabled ? 'работает' : 'остановлена'}. Последний обход: ${probe.last_tick ? escapeHtml(new Date(probe.last_tick * 1000).toLocaleString()) : 'ещё не выполнялся'}. ${escapeHtml(probe.error || '')}

`); + } renderHealthPanels(currentSnapshot); const warningsContainer = document.getElementById('health-warnings-list'); if (warningsContainer) { @@ -2452,9 +2457,10 @@ async function handleNodeModelChange(roleId, profileId, newModel) { async function handleRefreshProviderModels(providerId, profileId = null) { showToast(`Запрос списка моделей для ${providerId}...`, 'info'); - const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' }); + const res = await executeAction('refresh_models', { provider: providerId, profile_id: profileId || '' }); if (res && res.ok) { - showToast('Запрос обновления моделей отправлен', 'success'); + showToast(res.message, 'success'); + await fetchSnapshot(); if (profileId) { setTimeout(() => openAccountDetailsModal(profileId, true), 500); } else { @@ -2680,12 +2686,14 @@ function openAddAccountWizard() { window._wiz_redirect_slot_id = undefined; window._wiz_base_url = undefined; window._wiz_token = undefined; + window._wiz_models = undefined; if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; showWizardStep1(); showModal(); } function showWizardStep1() { + window._wiz_models = undefined; stopDeviceAuthPolling(); stopRedirectAuthPolling(); for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined; @@ -2771,6 +2779,7 @@ function showWizardStep2(providerId) { window._wiz_device_profile = undefined; window._wiz_base_url = undefined; window._wiz_token = undefined; + window._wiz_models = undefined; } window._wiz_provider = providerId; let bodyHtml = ''; @@ -2932,7 +2941,12 @@ function showWizardStep2(providerId) { elements.modalFooter.innerHTML = footerHtml; } -function proceedToWizardStep3(providerId) { +async function proceedToWizardStep3(providerId) { + if (window._wiz_validating) return; + window._wiz_validating = true; + const nextButton = elements.modalFooter?.querySelector('.btn-primary'); + if (nextButton) nextButton.disabled = true; + try { const baseInput = document.getElementById('wiz-base-url-input'); if (baseInput) { window._wiz_base_url = baseInput.value.trim(); @@ -2954,7 +2968,21 @@ function proceedToWizardStep3(providerId) { window._wiz_device_profile = redirectSlot?.value || ''; } // For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements + if (tokenInput || baseInput) { + const feedback = document.getElementById('modal-feedback-area'); + if (feedback) feedback.textContent = 'Проверка подключения и запрос моделей…'; + const result = await executeAction('validate_connection', {provider: providerId, token: window._wiz_token || '', base_url: window._wiz_base_url || ''}); + if (!result?.ok) { + if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера'; + return; + } + window._wiz_models = result.data.models; + } showWizardStep3(providerId); + } finally { + window._wiz_validating = false; + if (nextButton) nextButton.disabled = false; + } } function showWizardStep3(providerId) { @@ -2988,6 +3016,7 @@ function showWizardStep3(providerId) {
Шаг 3 из 3: Назначение роли для нового аккаунта
+ ${window._wiz_models?.length ? `` : ''}