docs(web): контракт веб-интерфейса и эталонная фикстура снапшота
Переход на веб-интерфейс. Контракт вынесен отдельным документом, потому что серверная и клиентская стороны делаются параллельно и до слияния друг друга не видят: в прошлом раунде расхождение в одном имени модуля молча оставило выбор моделей пустым навсегда. Зафиксировано: стек (FastAPI + uvicorn, уже объявлены в pyproject; клиент — обычный JS без сборки), требования безопасности (127.0.0.1 по умолчанию, токен для внешнего адреса, тест на отсутствие секретов в ответе), два эндпоинта поверх готового снапшота и семнадцати действий _handle_action, правило игнорировать ответы с устаревшим seq. snapshot.example.json снят с живой машины владельца: 63 профиля, 187 КБ. Почты замаскированы, токенов, ключей и JWT нет — проверено. Клиентская сторона разрабатывается против этого файла и не блокируется готовностью сервера. Отдельно зафиксировано ограничение headless: Codex и Grok работают по device-code, Antigravity и Claude используют redirect на localhost и на сервере без экрана не заработают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
289fb45313
commit
7ae4a28a80
2 changed files with 5661 additions and 0 deletions
135
docs/web-api/CONTRACT.md
Normal file
135
docs/web-api/CONTRACT.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Контракт веб-интерфейса Hermes Hub
|
||||||
|
|
||||||
|
Версия контракта: **1.0**
|
||||||
|
Дата: 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ: `200` с телом. При ошибке сбора — `503` и тело `{"error": "<причина по-русски>"}`.
|
||||||
|
|
||||||
|
### `POST /api/action`
|
||||||
|
|
||||||
|
Единая точка для всех действий. Тело:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "action": "<имя>", "data": { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена действий берутся **ровно** из существующего `_handle_action` в `hermes_hub_app.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_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>"}`. Без авторизации — нужен для проверки, что сервер поднялся.
|
||||||
|
|
||||||
|
## 5. Обновление данных
|
||||||
|
|
||||||
|
Первая версия — **опрос**: клиент запрашивает `/api/snapshot` раз в 5 секунд и при действиях пользователя.
|
||||||
|
|
||||||
|
Поле `seq` в снапшоте монотонно растёт. Клиент обязан **игнорировать ответ с `seq` меньше уже применённого** — иначе поздний ответ затрёт свежий. Этот дефект в десктопе уже ловили, повторять не нужно.
|
||||||
|
|
||||||
|
Server-Sent Events — следующий шаг, в эти задания не входит. Закладывать под них структуру не нужно; опрос заменяется без переделки клиента.
|
||||||
|
|
||||||
|
## 6. Честность данных — правило распространяется на веб целиком
|
||||||
|
|
||||||
|
Прежнее и главное правило проекта действует без исключений:
|
||||||
|
|
||||||
|
- **ни одного числа, идентификатора или названия модели без измерения;**
|
||||||
|
- нет данных — «Н/Д» **и причина рядом**, доступная пользователю. Причина уже приходит в снапшоте полем `unavailable_reason`;
|
||||||
|
- **отличать «данных нет» от «данные ещё грузятся».** Сейчас это не различается, и владелец видел пустые карточки без объяснения — квота появляется только после фонового опроса. В вебе состояние загрузки обязано выглядеть как загрузка.
|
||||||
|
|
||||||
|
## 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 либо авторизация на десктопе с переносом профиля), а не показывать неработающую кнопку.
|
||||||
5526
docs/web-api/snapshot.example.json
Normal file
5526
docs/web-api/snapshot.example.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue