hermes-hub/docs/web-api/CONTRACT.md
Hermes Team 3374246941 Merge A17 (честный статус) и A18 (выбор модели)
A17 принято, проверено на живых профилях владельца: «Работает» больше не
ставится непроверенному профилю. Из 22 профилей теперь 1 «Работает»
(есть записанный успех), 7 «Не проверялся», 11 «Аккаунт не добавлен»,
3 «Отключён». Grok и opengo-1, на которые жаловался владелец, показаны
честно. Добавлено поле last_success_at.

A18 принято частично: действие set_model существует и валидирует модель
по списку провайдера. Но обнаружение моделей (P0-2) не сделано вовсе —
model_discovery_service и зонд не менялись, ручного обновления нет,
кэша на диске нет. Из-за этого set_model отклоняет ЛЮБУЮ модель, включая
настоящую: список провайдера пуст, и валидация не с чем сравнивать.

Правка при слиянии: discover_models запускался в ГЛОБАЛЬНОМ окружении,
где вход agy не выполнен, — при шести рабочих OAuth-профилях. Теперь
принимает profile_id и подменяет HOME/USERPROFILE на каталог профиля,
как это делает adapter.invoke. Таймаут поднят с 10 до 60 секунд.

Это не вылечило симптом, и причина оказалась глубже — см. отчёт.

Тесты: 336 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:24:22 +07:00

161 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Контракт веб-интерфейса Hermes Hub
Версия контракта: **1.1**
Дата: 2026-08-23
Этот документ — **единственный** источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят.
Правило, купленное дорого: в прошлом раунде служба обнаружения моделей была написана правильно, но названа `model_discovery_service.py`, тогда как потребитель импортировал `model_discovery`. Импорт был обёрнут в `except ImportError`, поэтому расхождение **не дало ошибки** — выбор моделей просто остался пустым навсегда. Здесь то же самое повторится в виде «страница грузится, но данных нет», если стороны разойдутся хоть в одном имени.
Поэтому: **любое отклонение от этого документа — дефект, даже если код работает.** Меняется контракт — сначала правится документ и согласуется с обеими сторонами.
---
## 1. Стек и его обоснование
**Сервер:** FastAPI + uvicorn. Обе зависимости уже объявлены в `pyproject.toml` в группе `legacy` — она осталась от удалённого `gui_server.py` и сейчас не используется ничем. Переименовать группу в `web` и подключить.
**Клиент:** обычный JavaScript и `fetch`, **без сборки, без npm, без фреймворка**.
Обоснование, а не вкусовщина. Проект ведут агенты на трёх разных машинах; любой шаг сборки означает согласование версий Node, установку зависимостей и дрейф lock-файлов между машинами. Интерфейс и так был единственным узким местом всех прошлых раундов. Страница, которую можно открыть файлом и отладить в браузере без инструментов, снимает целый класс проблем. React в этом проекте отклонён и раньше — по той же причине.
## 2. Границы
Веб-интерфейс **дополняет** десктоп, а не заменяет его немедленно. Десктопное приложение обязано продолжать работать до тех пор, пока веб не достигнет паритета. Ничего из `router/ui/**` не удаляется в рамках этих заданий.
Веб-слой живёт в новом пакете:
```
src/antigravity_provider/router/web/
__init__.py
server.py # FastAPI-приложение и запуск uvicorn
api.py # эндпоинты
security.py # привязка к адресу, токен
static/
index.html
app.js
style.css
```
## 3. Безопасность — обязательная часть, не опция
Веб-интерфейс будет работать на сервере, доступном по сети. Требования:
1. **По умолчанию слушать только `127.0.0.1`.** Другой адрес — только явным параметром запуска, и при этом **обязателен токен**.
2. Токен передаётся заголовком `X-Hub-Token`, сравнивается через `secrets.compare_digest`. При запуске без токена на не-локальном адресе сервер **отказывается стартовать** с внятным сообщением, а не поднимается открытым.
3. **Секреты не покидают сервер ни при каких условиях.** Проверено на живых данных: сериализованный снапшот не содержит ни `access_token`, ни `refresh_token`, ни `api_key`, ни JWT. Это состояние обязано сохраниться — на стороне сервера нужен тест, который падает при появлении в ответе любого из этих ключей.
4. Почты аккаунтов в снапшоте присутствуют открытым текстом (43 вхождения на машине владельца). Это персональные данные, не учётные. Маскирование — решение владельца; по умолчанию отдаём как есть, поскольку интерфейс без идентичности аккаунта бесполезен.
5. Никаких секретов в URL и параметрах запроса.
## 4. Эндпоинты
### `GET /api/snapshot`
Возвращает всё состояние интерфейса одним объектом.
Формат — **точный результат `dataclasses.asdict(HubSnapshot)`** с сериализацией `datetime` в ISO-8601. Проверено исполнением: снапшот сериализуется одним вызовом, объём около 100 КБ.
Эталонный пример лежит рядом: **`docs/web-api/snapshot.example.json`** — снят с живой машины владельца, почты замаскированы. Клиентская сторона разрабатывается против этого файла и до готовности сервера в нём не нуждается.
Ключи верхнего уровня:
```
generation, seq, timestamp, profiles_by_provider, all_profiles,
readiness, agents, providers, routing, quotas, metrics, is_stale
```
**Обновление схемы ProfileViewModel:**
В рамках задачи A17 добавлены новые поля и состояния для отслеживания честного статуса:
- `health_state` может принимать значение `not_tested`, если профиль никогда не проверялся.
- Добавлено поле `last_success_at` (время последней успешной проверки, `%H:%M:%S`).
Ответ: `200` и JSON. При ошибках сборки — `503` с телом `{"error": "<описание проблемы>"}`.
### `POST /api/action`
Единая точка для всех действий. Тело:
```json
{ "action": "<имя>", "data": { ... } }
```
Имена действий берутся **ровно** из общего слоя `action_handler.py`. Их восемнадцать:
```
account_details add_account agent_settings assign_role
auto_assign_all check_updates delete_credentials edit_route
oauth open_routing refresh_account refresh_all
refresh_data save_settings set_main set_model
set_orchestrator test
```
Ответ:
```json
{ "ok": true, "message": "<текст для пользователя>", "data": { ... } }
{ "ok": false, "message": "<причина отказа по-русски>" }
```
**`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации.
Действия `open_routing` и `account_details` в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
### `GET /api/health`
`{"ok": true, "version": "<версия Hub>", "auth_flows": {...}}`. Без авторизации — нужен для проверки, что сервер поднялся.
Поле `auth_flows` содержит информацию о доступности потоков авторизации на сервере. Формат:
```json
{
"auth_flows": {
"openai-codex": {"supported": true, "reason": "device-code"},
"grok": {"supported": true, "reason": "device-code"},
"antigravity": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"},
"claude": {"supported": false, "reason": "Требует redirect на localhost; используйте десктоп или проброс портов"}
}
}
```
Веб-клиент **обязан** проверять это поле и не показывать неработающую кнопку, а предлагать обходной путь.
### `GET /` и статика
**Сервер обязан отдавать сам интерфейс, а не только API.** Корневой маршрут возвращает `static/index.html`, каталог `static/` монтируется целиком.
Пункт добавлен постфактум: в версии 1.0 контракт описал каталог `static/` в структуре пакета, но не назвал, **кто его отдаёт**. Обе стороны выполнили написанное — API отвечал, файлы клиента лежали в репозитории — и всё равно не собрались: в браузере был `404`. Ответственность за пропуск на авторе контракта, а не на исполнителях.
Урок общего свойства: в контракте между двумя сторонами недостаточно описать, что каждая делает. Нужно назвать, **где именно они стыкуются** и кто отвечает за стык.
## 5. Обновление данных
Первая версия — **опрос**: клиент запрашивает `/api/snapshot` раз в 5 секунд и при действиях пользователя.
Поле `seq` в снапшоте монотонно растёт. Клиент обязан **игнорировать ответ с `seq` меньше уже применённого** — иначе поздний ответ затрёт свежий. Этот дефект в десктопе уже ловили, повторять не нужно.
Server-Sent Events — следующий шаг, в эти задания не входит. Закладывать под них структуру не нужно; опрос заменяется без переделки клиента.
## 6. Честность данных — правило распространяется на веб целиком
Прежнее и главное правило проекта действует без исключений:
- **ни одного числа, идентификатора или названия модели без измерения;**
- нет данных — «Н/Д» **и причина рядом**, доступная пользователю. Причина уже приходит в снапшоте полем `unavailable_reason`;
- **отличать «данных нет» от «данные ещё грузятся».** Сейчас это не различается, и владелец видел пустые карточки без объяснения — квота появляется только после фонового опроса. В вебе состояние загрузки обязано выглядеть как загрузка. Для этого в объект квоты (в `quotas` и связанных местах) добавлено поле `is_loading: bool`. Если `is_loading == true`, значит идёт опрос; если `false` и нет квот — значит данных действительно нет.
## 7. Что известно про Linux заранее
Проверено по коду, чтобы никто не выяснял это заново:
- `paths.py` уже кроссплатформенный: при отсутствии `LOCALAPPDATA` уходит в `~/.hermes`;
- поиск CLI провайдеров уже готов: `shutil.which("agy") or shutil.which("agy.exe")`;
- **восемь мест дублируют логику `LOCALAPPDATA` в обход `paths.py`** и на Linux дадут неверные пути: `hermes_hub_app`, `router_config` (две штуки), `launcher_bootstrap`, `model_discovery_service`, `ui/assets`, `agy_subprocess`.
**Авторизация на headless-сервере** — ограничение, которое надо признать честно, а не обойти молча:
| Провайдер | Поток | На сервере без экрана |
|---|---|---|
| OpenAI Codex | device-code | **работает** — код вводится с любой машины |
| Grok | device-code | **работает** |
| Antigravity | redirect на localhost | **не работает** — редирект придёт на машину пользователя, а не сервера |
| Claude | redirect на localhost | **не работает** |
Для двух последних интерфейс обязан сказать об этом прямо и предложить обходной путь (проброс порта по SSH либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.