hermes-hub/docs/web-api/CONTRACT.md

263 lines
25 KiB
Markdown
Raw Permalink 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.4**
Дата: 2026-08-24
Этот документ — **единственный** источник истины для двух сторон: серверной (задание 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/**` не удаляется в рамках этих заданий.
Веб-клиент организован в **7 функциональных разделов** (в порядке меню навигации):
1. **`overview` (Обзор)** — Сводные счетчики слотов и подключений провайдеров, схема маршрутизации и выбор моделей ролей прямо на схеме.
2. **`accounts` (Аккаунты)** — Список подключенных профилей, статусы авторизации и оперативные квоты.
3. **`routing` (Маршрутизация)** — Главный центр управления: перетаскивание узлов Drag-and-Drop, выбор модели на узле, добавление (+ Добавить) и удаление (✕) профилей из цепочек.
4. **`analytics` (Аналитика)** — Телеметрия вызовов и задержек за 24ч (p50/p95/max), пояснение fast-fail и честное отображение неподключенных провайдеров.
5. **`health` (Состояние)** — Готовность системы, мониторинг системных ресурсов и рисков.
6. **`logs` (Журнал событий)** — Обратный хронологический журнал событий Hermes Hub.
7. **`settings` (Настройки)** — Параметры веб-сервера, токены, интервалы и системные пути.
Веб-слой живёт в пакете:
```
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`:
Действия Agent Manager и Workflow (A30):
| Action | Назначение | Обязательные данные |
|---|---|---|
| `create_agent` | Создать логического агента, роль маршрутизатора и Agent File | `name`, `role`; опционально `profile_id`, `model`, настройки исполнения |
| `update_agent` | Изменить свойства и назначение Provider → Account → Model | `agent_id`; назначение задаётся `provider`, `profile_id`, `model` |
| `delete_agent` | Удалить агента; при ссылках сначала возвращает `confirmation_required` | `agent_id`; после подтверждения `force: true` |
| `read_agent_file` | Прочитать реальный Markdown Agent File | `agent_id` |
| `save_agent_file` | Атомарно сохранить Agent File для последующих запусков | `agent_id`, `content` |
| `save_workflow` | Валидировать и сохранить узлы, рёбра, layout и предел итераций | `edges`, `agents`, `max_iterations` |
| `start_workflow` | Запустить реальную задачу через RouterEngine | `task` |
| `stop_workflow` | Запросить остановку текущего запуска | — |
| `run_preflight` | Запустить zero-quota проверку зависимостей, CLI, Python окружения и локальных серверов | — |
Состояние графа и LIVE-журнал приходят в поле `workflow` ответа
`GET /api/snapshot`. `workflow.run.status=loading` означает загрузку;
`unavailable_reason` означает отсутствие данных с явной причиной. Показатели
workflow нельзя подменять фикстурой при недоступности API.
```
account_details add_account agent_settings apply_update
assign_role auto_assign_all check_updates delete_credentials
edit_route get_update_status oauth open_routing
refresh_account refresh_all refresh_data refresh_models
reorder_chain run_preflight save_chain save_settings
set_main set_model set_orchestrator test
```
Ответ:
```json
{ "ok": true, "message": "<текст для пользователя>", "data": { ... } }
{ "ok": false, "message": "<причина отказа по-русски>" }
```
**`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации.
- **`check_updates`** / **`apply_update`** / **`get_update_status`** (A27): проверка и установка обновлений из самой программы. `check_updates` опрашивает GitHub API релизов основного репозитория `ochenstarik-ui/hermes-hub` и сравнивает установленный коммит со сборкой последнего релиза. `apply_update` загружает установщик/пакет, проверяет sha256 по `checksums.txt` и запускает обновление с сохранением пользовательских данных и настроек.
- **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`.
- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`.
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.
**`start_device_auth` / `poll_device_auth`** (добавлено при разборе жалобы «не даёт зайти в грок»): авторизация по коду устройства для Grok и OpenAI Codex прямо из веба. Раньше поток был только в десктопе, а веб-мастер показывал заглушку.
`start_device_auth``data: {"provider": "grok"|"openai-codex"}`. Возвращает `{"session_id", "url", "code", "profile_id"}`. **Адрес и код выдаёт провайдер**; интерфейс их только отображает и не имеет права подставлять свои.
`poll_device_auth``data: {"provider", "session_id"}`. `ok: true` со `status: "pending"` означает ожидание подтверждения; `status: "completed"` — аккаунт подключён; `ok: false` — конечный отказ с причиной (пользователь отклонил, код истёк, сессия не найдена). Отказ и просрочку клиент обязан показывать как окончательные и прекращать опрос.
**`start_redirect_auth` / `submit_redirect_callback` / `poll_redirect_auth` / `cancel_redirect_auth`** (добавлено при разборе жалобы «не даёт добавлять аккаунты на линукс»): вход по ссылке для **Antigravity** и **Claude**.
Раньше веб-мастер утверждал, что вход через веб «невозможен», и отправлял в консоль по SSH, а `GET /api/health` отдавал для этих двух провайдеров жёстко вписанное `supported: false`. Утверждение неверно: `ProfileOAuthSession.handle_manual_callback_url` и `ClaudeOAuthSession.handle_auth_code` принимают вставленное вручную значение. **Браузер нужен где угодно, а не на машине с Hub.**
`start_redirect_auth``data: {"provider": "antigravity"|"claude", "profile_id"?}`. Возвращает `{"session_id", "url", "port", "redirect_uri", "profile_id", "paste_kind"}`. `paste_kind``"url"` (Antigravity кладёт код в адресную строку) или `"code"` (Claude показывает код на странице); подсказка в интерфейсе обязана различаться, иначе владелец ищет не то. **Ссылку выдаёт провайдер**, интерфейс не имеет права подставлять свою.
`profile_id` передаёт **клиент**, а не подбирает сервер. `AutoAssigner.find_free_slot` определяет занятость по файлу учётных данных, но `agy` на Windows хранит их в keyring — файла нет ни у одного слота, поэтому все считаются свободными и всегда возвращается первый. Вход затёр бы работающий аккаунт. Слот выбирает владелец из списка, построенного по снапшоту.
`submit_redirect_callback``data: {"session_id", "provider", "callback_url"}`. Ошибки разбора (нет кода, чужой `state`) **не завершают сессию**: владелец переносит значение между машинами руками и легко промахивается, а ссылка остаётся годной. Конечны только отказ провайдера и отмена.
`poll_redirect_auth``data: {"session_id"}`. `status: "pending"` — ожидание; `"completed"` — подключено; `ok: false` — конечный отказ. Закрытие слушателя по таймауту (20 минут) **не** конечный отказ: вставить адрес вручную можно и после него.
### `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/events`
Возвращает список недавних событий системы из `EventLogService` в обратном хронологическом порядке.
Параметры запроса (Query Params):
- `limit` (int, необязательно, по умолчанию `50`, максимум `200`): количество событий;
- `category` (string, необязательно): фильтрация по категории (`account`, `quota`, `routing`, `auth`, `system`).
Ответ:
```json
{
"events": [
{
"timestamp": "22:50:14",
"category": "system",
"message": "Успешная проверка подключения ag-w2 (gemini-3.1-pro-high) за 1.4s.",
"details": "...",
"level": "success"
}
]
}
```
**Секреты в события не попадают.** На эндпоинт распространяется санитайзер `sanitize_snapshot`.
---
### `GET /api/settings`
Возвращает текущие настройки из `hub_settings.json` и пути системы.
**Секреты не отдаются:** поле `web_api_token` скрыто; наружу отдаётся только флаг `web_api_token_configured` (`true`/`false`).
Ответ:
```json
{
"web_api_host": "127.0.0.1",
"web_api_port": 5800,
"web_api_token_configured": false,
"theme": "system",
"quota_refresh_interval_sec": 300,
"hermes_home": "C:\\Users\\...\\.hermes",
"config_dir": "C:\\Users\\...\\.hermes\\config",
"log_file": "C:\\Users\\...\\.hermes\\logs\\hermes-hub.log"
}
```
Сохранение настроек выполняется через стандартное действие `POST /api/action` с `action: "save_settings"`.
---
### `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 либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.