feat(a31): preflight dependency agent, workflow run state recovery, local concurrency and context guard, PII email masking, and cost-controller token honesty
This commit is contained in:
parent
8b8aebf928
commit
59d57a41ef
14 changed files with 1365 additions and 14 deletions
|
|
@ -102,6 +102,7 @@ readiness, agents, providers, routing, quotas, metrics, is_stale
|
|||
| `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` означает загрузку;
|
||||
|
|
@ -113,10 +114,11 @@ 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 save_chain save_settings set_main
|
||||
set_model set_orchestrator test
|
||||
reorder_chain run_preflight save_chain save_settings
|
||||
set_main set_model set_orchestrator test
|
||||
```
|
||||
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@ def get_workflow_state_path() -> Path:
|
|||
return get_config_dir() / "workflow_state.json"
|
||||
|
||||
|
||||
def get_workflow_run_state_path() -> Path:
|
||||
"""Return the persisted active workflow run state file."""
|
||||
return get_config_dir() / "workflow_run_state.json"
|
||||
|
||||
|
||||
def get_agent_files_dir() -> Path:
|
||||
"""Return the user-editable directory containing real Agent Files."""
|
||||
directory = get_hermes_home() / "agents"
|
||||
|
|
|
|||
|
|
@ -630,5 +630,12 @@ class ActionExecutor:
|
|||
status = mgr.get_status_dict()
|
||||
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
||||
|
||||
elif action == 'run_preflight':
|
||||
from antigravity_provider.router.preflight_service import PreflightCheckService
|
||||
service = PreflightCheckService.get()
|
||||
report = service.run_all_checks()
|
||||
msg = f"Проверка готовности: {report.passed_count} успешно, {report.failed_count} ошибок, {report.warn_count} предупреждений"
|
||||
return {'ok': report.success, 'message': msg, 'data': report.to_dict()}
|
||||
|
||||
else:
|
||||
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,79 @@ class LocalLLMAdapter(BaseProviderAdapter):
|
|||
return val
|
||||
return None
|
||||
|
||||
_context_window_cache: Dict[str, int] = {}
|
||||
|
||||
def get_context_window(
|
||||
self,
|
||||
profile: RouterProfileConfig,
|
||||
model: Optional[str] = None,
|
||||
query_remote: bool = False,
|
||||
) -> Optional[int]:
|
||||
"""Fetch actual context_window / max_context_length from profile config or /models endpoint.
|
||||
|
||||
Never invents or hardcodes defaults. Returns None if unknown.
|
||||
"""
|
||||
# 1. Profile auth_config / custom settings
|
||||
for key in ("context_window", "context_length", "max_context_length", "max_tokens_limit", "n_ctx"):
|
||||
if key in profile.auth_config and profile.auth_config[key]:
|
||||
try:
|
||||
return int(profile.auth_config[key])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. In-memory cache from previous model discovery
|
||||
cache_key = f"{profile.profile_id}:{model or 'default'}"
|
||||
if cache_key in self._context_window_cache:
|
||||
return self._context_window_cache[cache_key]
|
||||
if f"{profile.profile_id}:all" in self._context_window_cache:
|
||||
return self._context_window_cache[f"{profile.profile_id}:all"]
|
||||
|
||||
if not query_remote:
|
||||
return None
|
||||
|
||||
# 3. Query /models endpoint
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
headers = {"Accept": "application/json", "User-Agent": "hermes-router/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
req = urllib.request.Request(f"{base_url}/models", headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
||||
items = data.get("data") or data.get("models") or []
|
||||
if isinstance(items, list):
|
||||
for m in items:
|
||||
if isinstance(m, dict):
|
||||
m_id = str(m.get("id") or m.get("name") or "")
|
||||
for ck in ("context_window", "context_length", "max_model_len", "max_context_length", "n_ctx"):
|
||||
if ck in m and m[ck]:
|
||||
try:
|
||||
ctx_val = int(m[ck])
|
||||
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
|
||||
self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
|
||||
if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
|
||||
return ctx_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
meta = m.get("meta") or {}
|
||||
if isinstance(meta, dict):
|
||||
for ck in ("n_ctx", "context_length", "max_context_length"):
|
||||
if ck in meta and meta[ck]:
|
||||
try:
|
||||
ctx_val = int(meta[ck])
|
||||
self._context_window_cache[f"{profile.profile_id}:{m_id}"] = ctx_val
|
||||
self._context_window_cache[f"{profile.profile_id}:all"] = ctx_val
|
||||
if not model or m_id == model or model in m_id or m_id in model or len(items) == 1:
|
||||
return ctx_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query context window from server for %s: %s", profile.profile_id, exc)
|
||||
|
||||
return None
|
||||
|
||||
def invoke(self, profile: RouterProfileConfig, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
base_url = self._resolve_base_url(profile)
|
||||
api_key = self._resolve_api_key(profile)
|
||||
|
|
@ -54,9 +127,43 @@ class LocalLLMAdapter(BaseProviderAdapter):
|
|||
if not model or model == "default":
|
||||
model = profile.preferred_models[0] if profile.preferred_models else "default"
|
||||
|
||||
messages = list(request.get("messages", []))
|
||||
|
||||
# Context Truncation Guard: safely bound prompt if context_window is known to prevent VRAM overflow
|
||||
context_window = self.get_context_window(profile, model, query_remote=False)
|
||||
if context_window is not None and context_window > 0 and len(messages) > 1:
|
||||
max_tok = int(request.get("max_tokens", 0) or 0)
|
||||
token_budget = context_window - max_tok - 64
|
||||
if token_budget > 100:
|
||||
def _est_tok(msgs: list) -> int:
|
||||
total_chars = sum(len(str(m.get("content", ""))) for m in msgs if isinstance(m, dict))
|
||||
return int(total_chars / 3.5) + len(msgs) * 4
|
||||
|
||||
if _est_tok(messages) > token_budget:
|
||||
logger.warning(
|
||||
"Context truncation guard active for %s: prompt exceeds context window (%d). Truncating middle messages.",
|
||||
profile.profile_id,
|
||||
context_window,
|
||||
)
|
||||
system_msg = [messages[0]] if messages and messages[0].get("role") == "system" else []
|
||||
last_msg = messages[-1]
|
||||
middle = messages[1:-1] if system_msg else messages[:-1]
|
||||
|
||||
while middle and _est_tok(system_msg + middle + [last_msg]) > token_budget:
|
||||
middle.pop(0)
|
||||
|
||||
if _est_tok(system_msg + middle + [last_msg]) > token_budget:
|
||||
avail_chars = max(100, int(token_budget * 3.0))
|
||||
last_copy = dict(last_msg)
|
||||
last_copy["content"] = str(last_copy.get("content", ""))[-avail_chars:]
|
||||
messages = system_msg + middle + [last_copy]
|
||||
else:
|
||||
messages = system_msg + middle + [last_msg]
|
||||
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": request.get("messages", []),
|
||||
"messages": messages,
|
||||
"temperature": request.get("temperature", 0.7),
|
||||
}
|
||||
if "tools" in request and request["tools"]:
|
||||
|
|
@ -105,6 +212,7 @@ class LocalLLMAdapter(BaseProviderAdapter):
|
|||
return data
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _reject_empty_answer(data: Dict[str, Any]) -> None:
|
||||
"""Пустой ответ — это отказ, а не успех.
|
||||
|
|
|
|||
394
src/antigravity_provider/router/preflight_service.py
Normal file
394
src/antigravity_provider/router/preflight_service.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
"""Hermes Hub — Preflight Dependency & Readiness Check Service (Dependency Agent).
|
||||
|
||||
Performs comprehensive zero-quota preflight validation of local environment, CLI tools,
|
||||
Python dependencies, local inference endpoints, role chain credentials, and disk permissions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
|
||||
logger = logging.getLogger("hermes.router.preflight")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreflightItem:
|
||||
check_id: str
|
||||
name: str
|
||||
status: str # "PASS" | "FAIL" | "WARN"
|
||||
message: str
|
||||
remediation: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreflightReport:
|
||||
success: bool
|
||||
passed_count: int
|
||||
failed_count: int
|
||||
warn_count: int
|
||||
checks: List[PreflightItem] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": self.success,
|
||||
"passed_count": self.passed_count,
|
||||
"failed_count": self.failed_count,
|
||||
"warn_count": self.warn_count,
|
||||
"checks": [c.to_dict() for c in self.checks],
|
||||
}
|
||||
|
||||
|
||||
class PreflightCheckService:
|
||||
"""Zero-quota dependency and readiness inspection service."""
|
||||
|
||||
_instance: Optional[PreflightCheckService] = None
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> PreflightCheckService:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def check_cli_dependencies(self) -> List[PreflightItem]:
|
||||
"""Check for external CLI executables and critical Python packages."""
|
||||
items: List[PreflightItem] = []
|
||||
|
||||
# 1. Antigravity CLI (agy)
|
||||
agy_path = shutil.which("agy") or shutil.which("agy.exe")
|
||||
if agy_path:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="cli_agy",
|
||||
name="CLI Antigravity (agy)",
|
||||
status="PASS",
|
||||
message=f"Исполняемый файл agy найден: {agy_path}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="cli_agy",
|
||||
name="CLI Antigravity (agy)",
|
||||
status="WARN",
|
||||
message="Утилита 'agy' не найдена в системном PATH.",
|
||||
remediation="Установите agy CLI или добавьте каталог установки в системную переменную PATH.",
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Python package: fastapi
|
||||
fastapi_spec = importlib.util.find_spec("fastapi")
|
||||
if fastapi_spec is not None:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="pkg_fastapi",
|
||||
name="Библиотека FastAPI",
|
||||
status="PASS",
|
||||
message="Пакет fastapi успешно импортируется в окружении.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="pkg_fastapi",
|
||||
name="Библиотека FastAPI",
|
||||
status="FAIL",
|
||||
message="Пакет 'fastapi' не установлен в текущем Python окружении.",
|
||||
remediation="Выполните 'pip install fastapi' для работы веб-интерфейса и REST API.",
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Python package: uvicorn
|
||||
uvicorn_spec = importlib.util.find_spec("uvicorn")
|
||||
if uvicorn_spec is not None:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="pkg_uvicorn",
|
||||
name="Библиотека Uvicorn",
|
||||
status="PASS",
|
||||
message="Пакет uvicorn успешно импортируется в окружении.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="pkg_uvicorn",
|
||||
name="Библиотека Uvicorn",
|
||||
status="FAIL",
|
||||
message="Пакет 'uvicorn' не установлен в текущем Python окружении.",
|
||||
remediation="Выполните 'pip install uvicorn' для запуска веб-сервера.",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def check_local_servers(self) -> List[PreflightItem]:
|
||||
"""Poll {base_url}/models with 2.0s timeout for active local provider profiles."""
|
||||
items: List[PreflightItem] = []
|
||||
config = load_router_config()
|
||||
local_profiles = [p for p in config.profiles.values() if p.provider == "local" and p.enabled]
|
||||
|
||||
if not local_profiles:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="local_servers_none",
|
||||
name="Локальные серверы LLM",
|
||||
status="PASS",
|
||||
message="Активные локальные профили (llama.cpp/vLLM) не настроены.",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
|
||||
|
||||
adapter = LocalLLMAdapter()
|
||||
|
||||
for pcfg in local_profiles:
|
||||
base_url = adapter._resolve_base_url(pcfg)
|
||||
api_key = adapter._resolve_api_key(pcfg)
|
||||
headers = {"Accept": "application/json", "User-Agent": "hermes-preflight/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
models_url = f"{base_url}/models"
|
||||
try:
|
||||
req = urllib.request.Request(models_url, headers=headers, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||||
if resp.status in (200, 204):
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"local_srv_{pcfg.profile_id}",
|
||||
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
|
||||
status="PASS",
|
||||
message=f"Локальный сервер доступен (HTTP {resp.status}).",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"local_srv_{pcfg.profile_id}",
|
||||
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
|
||||
status="FAIL",
|
||||
message=f"Сервер вернул неожиданный статус HTTP {resp.status}",
|
||||
remediation=f"Проверьте настройки и логи сервера {base_url}.",
|
||||
)
|
||||
)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"local_srv_{pcfg.profile_id}",
|
||||
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
|
||||
status="FAIL",
|
||||
message=f"HTTP ошибка при обращении к {models_url}: {http_err.code} {http_err.reason}",
|
||||
remediation=f"Убедитесь, что сервер на {base_url} поддерживает OpenAI-совместимый эндпоинт /v1/models.",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"local_srv_{pcfg.profile_id}",
|
||||
name=f"Локальный сервер {pcfg.profile_id} ({base_url})",
|
||||
status="FAIL",
|
||||
message=f"Не удалось подключиться к {base_url}: {exc}",
|
||||
remediation=f"Запустите локальный сервер llama.cpp / vLLM / Ollama по адресу {base_url}.",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def check_auth_credentials(self) -> List[PreflightItem]:
|
||||
"""Verify credential presence for all profiles referenced in active role chains.
|
||||
|
||||
ZERO QUOTA BURN: Only inspects local auth files and keyring status. Never calls paid APIs.
|
||||
"""
|
||||
items: List[PreflightItem] = []
|
||||
config = load_router_config()
|
||||
|
||||
# Collect all profile IDs in active role chains
|
||||
referenced_pids: set[str] = set()
|
||||
for role_policy in config.roles.values():
|
||||
for pid in role_policy.preferred_chain:
|
||||
referenced_pids.add(pid)
|
||||
|
||||
if not referenced_pids:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="auth_chains_empty",
|
||||
name="Учетные данные цепочек ролей",
|
||||
status="WARN",
|
||||
message="В активных ролях не настроены цепочки профилей.",
|
||||
remediation="Настройте цепочки профилей в разделе Маршрутизация.",
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
for pid in sorted(referenced_pids):
|
||||
pcfg = config.get_profile(pid)
|
||||
if not pcfg:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"auth_{pid}",
|
||||
name=f"Профиль {pid}",
|
||||
status="FAIL",
|
||||
message=f"Профиль '{pid}' указан в цепочке роли, но отсутствует в конфигурации.",
|
||||
remediation=f"Удалите '{pid}' из цепочки роли или настройте профиль в router_profiles.yaml.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
status = ProfileAuthManager.get_profile_status(pcfg.provider, pid)
|
||||
is_authenticated = status.get("authenticated", False)
|
||||
is_expired = status.get("is_expired", False) or status.get("expired", False) or status.get("status") == "EXPIRED"
|
||||
|
||||
if is_authenticated and not is_expired:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"auth_{pid}",
|
||||
name=f"Авторизация {pid} ({pcfg.provider})",
|
||||
status="PASS",
|
||||
message="Учетные данные действительны и сохранены локально.",
|
||||
)
|
||||
)
|
||||
elif is_expired:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"auth_{pid}",
|
||||
name=f"Авторизация {pid} ({pcfg.provider})",
|
||||
status="FAIL",
|
||||
message=f"Срок действия авторизации для профиля '{pid}' истек.",
|
||||
remediation=f"Выполните повторный вход для профиля {pid} в разделе Аккаунты.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id=f"auth_{pid}",
|
||||
name=f"Авторизация {pid} ({pcfg.provider})",
|
||||
status="FAIL",
|
||||
message=f"Учетные данные для профиля '{pid}' ({pcfg.provider}) не найдены.",
|
||||
remediation=f"Подключите профиль {pid} через кнопку 'Добавить аккаунт' или 'hermes router login'.",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def check_system_environment(self) -> List[PreflightItem]:
|
||||
"""Verify HERMES_HOME presence and read/write permissions for config and logs."""
|
||||
items: List[PreflightItem] = []
|
||||
|
||||
# 1. HERMES_HOME directory
|
||||
try:
|
||||
home_dir = paths.get_hermes_home()
|
||||
if home_dir.is_dir():
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_hermes_home",
|
||||
name="Каталог HERMES_HOME",
|
||||
status="PASS",
|
||||
message=f"Каталог существует: {home_dir}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_hermes_home",
|
||||
name="Каталог HERMES_HOME",
|
||||
status="FAIL",
|
||||
message=f"Каталог {home_dir} не существует или не является директорией.",
|
||||
remediation="Проверьте права доступа и создайте каталог HERMES_HOME.",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_hermes_home",
|
||||
name="Каталог HERMES_HOME",
|
||||
status="FAIL",
|
||||
message=f"Ошибка доступа к HERMES_HOME: {exc}",
|
||||
remediation="Убедитесь, что переменная HERMES_HOME указывает на корректный доступный путь.",
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Config Directory Write Test
|
||||
try:
|
||||
config_dir = paths.get_config_dir()
|
||||
test_file = config_dir / f".preflight_probe_{os.getpid()}.tmp"
|
||||
test_file.write_text("probe", encoding="utf-8")
|
||||
test_file.unlink()
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_config_writable",
|
||||
name="Права на запись в каталог конфигурации",
|
||||
status="PASS",
|
||||
message=f"Права на запись в {config_dir} подтверждены.",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_config_writable",
|
||||
name="Права на запись в каталог конфигурации",
|
||||
status="FAIL",
|
||||
message=f"Нет прав на запись в {paths.get_config_dir()}: {exc}",
|
||||
remediation="Предоставьте текущему пользователю права на запись в каталог конфигурации.",
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Logs Directory Write Test
|
||||
try:
|
||||
logs_dir = paths.get_logs_dir()
|
||||
test_file = logs_dir / f".preflight_probe_{os.getpid()}.tmp"
|
||||
test_file.write_text("probe", encoding="utf-8")
|
||||
test_file.unlink()
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_logs_writable",
|
||||
name="Права на запись в каталог логов",
|
||||
status="PASS",
|
||||
message=f"Права на запись в {logs_dir} подтверждены.",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
items.append(
|
||||
PreflightItem(
|
||||
check_id="env_logs_writable",
|
||||
name="Права на запись в каталог логов",
|
||||
status="FAIL",
|
||||
message=f"Нет прав на запись в {paths.get_logs_dir()}: {exc}",
|
||||
remediation="Предоставьте текущему пользователю права на запись в каталог логов.",
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
def run_all_checks(self) -> PreflightReport:
|
||||
"""Run all readiness checks and return aggregated PreflightReport."""
|
||||
all_items: List[PreflightItem] = []
|
||||
all_items.extend(self.check_cli_dependencies())
|
||||
all_items.extend(self.check_system_environment())
|
||||
all_items.extend(self.check_auth_credentials())
|
||||
all_items.extend(self.check_local_servers())
|
||||
|
||||
passed = sum(1 for item in all_items if item.status == "PASS")
|
||||
failed = sum(1 for item in all_items if item.status == "FAIL")
|
||||
warn = sum(1 for item in all_items if item.status == "WARN")
|
||||
|
||||
return PreflightReport(
|
||||
success=(failed == 0),
|
||||
passed_count=passed,
|
||||
failed_count=failed,
|
||||
warn_count=warn,
|
||||
checks=all_items,
|
||||
)
|
||||
|
|
@ -178,6 +178,18 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = {
|
|||
max_failover_attempts=3,
|
||||
tier="expert",
|
||||
),
|
||||
"dependency-agent": RoleDefinition(
|
||||
role_id="dependency-agent",
|
||||
display_name_ru="Проверяющий готовность",
|
||||
short_name_ru="Готовность",
|
||||
description_ru="До начала задачи убеждается, что на месте всё необходимое — исполняемые файлы и CLI, библиотеки, учётные данные, права доступа, доступность локальных серверов. Сообщает о нехватке до запуска.",
|
||||
is_implemented=True,
|
||||
capabilities=["dependency-agent", "preflight", "environment", "system_checks", "fast"],
|
||||
fallback_capabilities=["dependency-agent", "preflight"],
|
||||
default_preferred_chain=["opengo-1", "ag-w1", "codex-worker-1"],
|
||||
max_failover_attempts=3,
|
||||
tier="qa_doc",
|
||||
),
|
||||
}
|
||||
|
||||
_CANONICAL_ROLE_ALIASES: Dict[str, str] = {
|
||||
|
|
@ -215,6 +227,13 @@ _CANONICAL_ROLE_ALIASES: Dict[str, str] = {
|
|||
"специалист по интеграции": "integration-expert",
|
||||
"безопасность": "security-expert",
|
||||
"специалист по безопасности": "security-expert",
|
||||
"dependency-agent": "dependency-agent",
|
||||
"dependency_agent": "dependency-agent",
|
||||
"preflight": "dependency-agent",
|
||||
"проверяющий готовность": "dependency-agent",
|
||||
"агент зависимостей": "dependency-agent",
|
||||
"готовность": "dependency-agent",
|
||||
"dependency": "dependency-agent",
|
||||
}
|
||||
|
||||
class RoleRegistry:
|
||||
|
|
@ -272,6 +291,10 @@ class RoleRegistry:
|
|||
clean = name_or_alias.strip().lower()
|
||||
return _CANONICAL_ROLE_ALIASES.get(clean, clean)
|
||||
|
||||
@classmethod
|
||||
def resolve_role_name(cls, name_or_alias: str) -> str:
|
||||
return cls.resolve_canonical_role(name_or_alias)
|
||||
|
||||
@classmethod
|
||||
def get_canonical_role_map(cls) -> Dict[str, str]:
|
||||
return dict(_CANONICAL_ROLE_ALIASES)
|
||||
|
|
|
|||
|
|
@ -303,16 +303,20 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
|
|||
profiles_raw = data.get("profiles", {})
|
||||
profiles: dict[str, RouterProfileConfig] = {}
|
||||
for pid, pdata in profiles_raw.items():
|
||||
provider = pdata.get("provider", "antigravity")
|
||||
max_concurrency = int(pdata.get("max_concurrency", 1))
|
||||
if provider == "local":
|
||||
max_concurrency = 1
|
||||
profiles[pid] = RouterProfileConfig(
|
||||
profile_id=pid,
|
||||
provider=pdata.get("provider", "antigravity"),
|
||||
provider=provider,
|
||||
account_id=pdata.get("account_id", pid),
|
||||
capabilities=list(pdata.get("capabilities", [])),
|
||||
preferred_models=list(pdata.get("preferred_models", [])),
|
||||
fallback_models=list(pdata.get("fallback_models", [])),
|
||||
auth_config=dict(pdata.get("auth_config", {})),
|
||||
enabled=bool(pdata.get("enabled", True)),
|
||||
max_concurrency=int(pdata.get("max_concurrency", 1)),
|
||||
max_concurrency=max_concurrency,
|
||||
custom_base_url=pdata.get("custom_base_url"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||
"monitoring_interval_seconds": 30,
|
||||
"quota_threshold_percent": 10.0,
|
||||
"quota_threshold_action": "notify",
|
||||
"email_masking_mode": "none",
|
||||
}
|
||||
|
||||
|
||||
|
||||
_SETTINGS_CACHE: Dict[str, Any] | None = None
|
||||
_SETTINGS_CACHE_MTIME: float = -1.0
|
||||
_SETTINGS_CACHE_PATH: str = ""
|
||||
|
|
@ -98,6 +100,11 @@ def get_hub_settings() -> Dict[str, Any]:
|
|||
action = "notify"
|
||||
merged["quota_threshold_action"] = action
|
||||
|
||||
email_mode = str(merged.get("email_masking_mode", "none")).strip().lower()
|
||||
if email_mode not in ("none", "partial", "full"):
|
||||
email_mode = "none"
|
||||
merged["email_masking_mode"] = email_mode
|
||||
|
||||
_SETTINGS_CACHE = dict(merged)
|
||||
_SETTINGS_CACHE_MTIME = current_mtime
|
||||
_SETTINGS_CACHE_PATH = sfile_str
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ MAX_FILE_BYTES = 5 * 1024 * 1024 # 5 MB
|
|||
MAX_BACKUP_FILES = 3
|
||||
|
||||
|
||||
def format_token_count(measured: Optional[int], estimated: Optional[int]) -> Optional[str]:
|
||||
"""Format token count distinguishing measured exact counts from estimations (~)."""
|
||||
if measured is not None:
|
||||
return str(measured)
|
||||
elif estimated is not None:
|
||||
return f"~{estimated}"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelemetryRecord:
|
||||
"""Immutable record of an individual router invocation attempt."""
|
||||
|
|
@ -45,6 +54,13 @@ class TelemetryRecord:
|
|||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
total_tokens: Optional[int] = None
|
||||
prompt_tokens_measured: Optional[int] = None
|
||||
prompt_tokens_estimated: Optional[int] = None
|
||||
completion_tokens_measured: Optional[int] = None
|
||||
completion_tokens_estimated: Optional[int] = None
|
||||
total_tokens_measured: Optional[int] = None
|
||||
total_tokens_estimated: Optional[int] = None
|
||||
is_estimated: bool = False
|
||||
cost_usd: Optional[float] = None
|
||||
failover_count: int = 0
|
||||
error_category: Optional[str] = None
|
||||
|
|
@ -69,6 +85,14 @@ class TelemetryAggregates:
|
|||
total_prompt_tokens: Optional[int] = None # Sum of reported prompt tokens or None if no token data
|
||||
total_completion_tokens: Optional[int] = None
|
||||
total_tokens: Optional[int] = None
|
||||
total_prompt_tokens_measured: Optional[int] = None
|
||||
total_prompt_tokens_estimated: Optional[int] = None
|
||||
total_completion_tokens_measured: Optional[int] = None
|
||||
total_completion_tokens_estimated: Optional[int] = None
|
||||
total_tokens_measured: Optional[int] = None
|
||||
total_tokens_estimated: Optional[int] = None
|
||||
tokens_display: Optional[str] = None
|
||||
has_estimated_tokens: bool = False
|
||||
total_cost_usd: Optional[float] = None # Sum of calculated costs or None if no pricing available
|
||||
failovers_count: int = 0
|
||||
failover_reasons: Dict[str, int] = field(default_factory=dict)
|
||||
|
|
@ -79,6 +103,7 @@ class TelemetryAggregates:
|
|||
return asdict(self)
|
||||
|
||||
|
||||
|
||||
class TelemetryService:
|
||||
"""Thread-safe persistent telemetry manager with bounded storage and honest aggregation."""
|
||||
|
||||
|
|
@ -176,16 +201,42 @@ class TelemetryService:
|
|||
total_tokens: Optional[int] = None,
|
||||
failover_count: int = 0,
|
||||
error_category: Optional[str] = None,
|
||||
prompt_tokens_measured: Optional[int] = None,
|
||||
prompt_tokens_estimated: Optional[int] = None,
|
||||
completion_tokens_measured: Optional[int] = None,
|
||||
completion_tokens_estimated: Optional[int] = None,
|
||||
total_tokens_measured: Optional[int] = None,
|
||||
total_tokens_estimated: Optional[int] = None,
|
||||
is_estimated: bool = False,
|
||||
) -> TelemetryRecord:
|
||||
"""Record an invocation attempt into memory and rotated log."""
|
||||
now = time.time()
|
||||
iso = datetime.datetime.fromtimestamp(now, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Derive total tokens if prompt/completion available
|
||||
if total_tokens is None and prompt_tokens is not None and completion_tokens is not None:
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
if is_estimated:
|
||||
p_est = prompt_tokens_estimated if prompt_tokens_estimated is not None else prompt_tokens
|
||||
c_est = completion_tokens_estimated if completion_tokens_estimated is not None else completion_tokens
|
||||
t_est = total_tokens_estimated if total_tokens_estimated is not None else total_tokens
|
||||
if t_est is None and p_est is not None and c_est is not None:
|
||||
t_est = p_est + c_est
|
||||
p_meas, c_meas, t_meas = None, None, None
|
||||
p_tok, c_tok, t_tok = p_est, c_est, t_est
|
||||
else:
|
||||
p_meas = prompt_tokens_measured if prompt_tokens_measured is not None else prompt_tokens
|
||||
c_meas = completion_tokens_measured if completion_tokens_measured is not None else completion_tokens
|
||||
t_meas = total_tokens_measured if total_tokens_measured is not None else total_tokens
|
||||
if t_meas is None and p_meas is not None and c_meas is not None:
|
||||
t_meas = p_meas + c_meas
|
||||
p_est = prompt_tokens_estimated
|
||||
c_est = completion_tokens_estimated
|
||||
t_est = total_tokens_estimated
|
||||
if t_est is None and p_est is not None and c_est is not None:
|
||||
t_est = p_est + c_est
|
||||
p_tok = p_meas if p_meas is not None else p_est
|
||||
c_tok = c_meas if c_meas is not None else c_est
|
||||
t_tok = t_meas if t_meas is not None else t_est
|
||||
|
||||
cost_usd = self.compute_cost(model, prompt_tokens, completion_tokens)
|
||||
cost_usd = self.compute_cost(model, p_tok, c_tok)
|
||||
|
||||
record = TelemetryRecord(
|
||||
timestamp=now,
|
||||
|
|
@ -196,9 +247,16 @@ class TelemetryService:
|
|||
model=model,
|
||||
outcome=outcome,
|
||||
latency_seconds=round(max(0.0, float(latency_seconds)), 4),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
prompt_tokens=p_tok,
|
||||
completion_tokens=c_tok,
|
||||
total_tokens=t_tok,
|
||||
prompt_tokens_measured=p_meas,
|
||||
prompt_tokens_estimated=p_est,
|
||||
completion_tokens_measured=c_meas,
|
||||
completion_tokens_estimated=c_est,
|
||||
total_tokens_measured=t_meas,
|
||||
total_tokens_estimated=t_est,
|
||||
is_estimated=is_estimated or (p_est is not None and p_meas is None),
|
||||
cost_usd=cost_usd,
|
||||
failover_count=failover_count,
|
||||
error_category=error_category,
|
||||
|
|
@ -268,6 +326,13 @@ class TelemetryService:
|
|||
prompt_tokens=d.get("prompt_tokens"),
|
||||
completion_tokens=d.get("completion_tokens"),
|
||||
total_tokens=d.get("total_tokens"),
|
||||
prompt_tokens_measured=d.get("prompt_tokens_measured"),
|
||||
prompt_tokens_estimated=d.get("prompt_tokens_estimated"),
|
||||
completion_tokens_measured=d.get("completion_tokens_measured"),
|
||||
completion_tokens_estimated=d.get("completion_tokens_estimated"),
|
||||
total_tokens_measured=d.get("total_tokens_measured"),
|
||||
total_tokens_estimated=d.get("total_tokens_estimated"),
|
||||
is_estimated=bool(d.get("is_estimated", False)),
|
||||
cost_usd=d.get("cost_usd"),
|
||||
failover_count=int(d.get("failover_count", 0)),
|
||||
error_category=d.get("error_category"),
|
||||
|
|
@ -318,6 +383,14 @@ class TelemetryService:
|
|||
total_prompt_tokens=None,
|
||||
total_completion_tokens=None,
|
||||
total_tokens=None,
|
||||
total_prompt_tokens_measured=None,
|
||||
total_prompt_tokens_estimated=None,
|
||||
total_completion_tokens_measured=None,
|
||||
total_completion_tokens_estimated=None,
|
||||
total_tokens_measured=None,
|
||||
total_tokens_estimated=None,
|
||||
tokens_display=None,
|
||||
has_estimated_tokens=False,
|
||||
total_cost_usd=None,
|
||||
failovers_count=0,
|
||||
failover_reasons={},
|
||||
|
|
@ -333,6 +406,15 @@ class TelemetryService:
|
|||
prompt_tokens_sum = 0
|
||||
completion_tokens_sum = 0
|
||||
has_any_token_data = False
|
||||
prompt_meas_sum = 0
|
||||
prompt_est_sum = 0
|
||||
has_meas_prompt = False
|
||||
has_est_prompt = False
|
||||
comp_meas_sum = 0
|
||||
comp_est_sum = 0
|
||||
has_meas_comp = False
|
||||
has_est_comp = False
|
||||
has_estimated_tokens = False
|
||||
costs_sum = 0.0
|
||||
has_any_cost_data = False
|
||||
failovers_count = 0
|
||||
|
|
@ -358,6 +440,41 @@ class TelemetryService:
|
|||
completion_tokens_sum += r.completion_tokens
|
||||
has_any_token_data = True
|
||||
|
||||
if r.prompt_tokens_measured is not None:
|
||||
prompt_meas_sum += r.prompt_tokens_measured
|
||||
has_meas_prompt = True
|
||||
elif r.prompt_tokens is not None and not r.is_estimated:
|
||||
prompt_meas_sum += r.prompt_tokens
|
||||
has_meas_prompt = True
|
||||
|
||||
if r.prompt_tokens_estimated is not None:
|
||||
prompt_est_sum += r.prompt_tokens_estimated
|
||||
has_est_prompt = True
|
||||
has_estimated_tokens = True
|
||||
elif r.prompt_tokens is not None and r.is_estimated:
|
||||
prompt_est_sum += r.prompt_tokens
|
||||
has_est_prompt = True
|
||||
has_estimated_tokens = True
|
||||
|
||||
if r.completion_tokens_measured is not None:
|
||||
comp_meas_sum += r.completion_tokens_measured
|
||||
has_meas_comp = True
|
||||
elif r.completion_tokens is not None and not r.is_estimated:
|
||||
comp_meas_sum += r.completion_tokens
|
||||
has_meas_comp = True
|
||||
|
||||
if r.completion_tokens_estimated is not None:
|
||||
comp_est_sum += r.completion_tokens_estimated
|
||||
has_est_comp = True
|
||||
has_estimated_tokens = True
|
||||
elif r.completion_tokens is not None and r.is_estimated:
|
||||
comp_est_sum += r.completion_tokens
|
||||
has_est_comp = True
|
||||
has_estimated_tokens = True
|
||||
|
||||
if r.is_estimated:
|
||||
has_estimated_tokens = True
|
||||
|
||||
if r.cost_usd is not None:
|
||||
costs_sum += r.cost_usd
|
||||
has_any_cost_data = True
|
||||
|
|
@ -369,6 +486,9 @@ class TelemetryService:
|
|||
|
||||
error_rate = round(failed_calls / total_calls, 4) if total_calls > 0 else 0.0
|
||||
total_tokens_sum = (prompt_tokens_sum + completion_tokens_sum) if has_any_token_data else None
|
||||
tot_meas_sum = (prompt_meas_sum + comp_meas_sum) if (has_meas_prompt or has_meas_comp) else None
|
||||
tot_est_sum = (prompt_est_sum + comp_est_sum) if (has_est_prompt or has_est_comp) else None
|
||||
tokens_display = format_token_count(tot_meas_sum, tot_est_sum)
|
||||
|
||||
return TelemetryAggregates(
|
||||
window_seconds=window_seconds,
|
||||
|
|
@ -383,6 +503,14 @@ class TelemetryService:
|
|||
total_prompt_tokens=prompt_tokens_sum if has_any_token_data else None,
|
||||
total_completion_tokens=completion_tokens_sum if has_any_token_data else None,
|
||||
total_tokens=total_tokens_sum,
|
||||
total_prompt_tokens_measured=prompt_meas_sum if has_meas_prompt else None,
|
||||
total_prompt_tokens_estimated=prompt_est_sum if has_est_prompt else None,
|
||||
total_completion_tokens_measured=comp_meas_sum if has_meas_comp else None,
|
||||
total_completion_tokens_estimated=comp_est_sum if has_est_comp else None,
|
||||
total_tokens_measured=tot_meas_sum,
|
||||
total_tokens_estimated=tot_est_sum,
|
||||
tokens_display=tokens_display,
|
||||
has_estimated_tokens=has_estimated_tokens,
|
||||
total_cost_usd=round(costs_sum, 4) if has_any_cost_data else None,
|
||||
failovers_count=failovers_count,
|
||||
failover_reasons=dict(failover_reasons),
|
||||
|
|
@ -390,6 +518,7 @@ class TelemetryService:
|
|||
has_data=True,
|
||||
)
|
||||
|
||||
|
||||
def get_breakdown(
|
||||
self,
|
||||
window_seconds: Optional[int] = 86400,
|
||||
|
|
|
|||
|
|
@ -125,8 +125,17 @@ def health_check():
|
|||
}
|
||||
}
|
||||
|
||||
def sanitize_snapshot(snap_dict: Any) -> Any:
|
||||
def sanitize_snapshot(snap_dict: Any, email_masking_mode: Optional[str] = None) -> Any:
|
||||
import re
|
||||
if email_masking_mode is None:
|
||||
try:
|
||||
from antigravity_provider.router.settings_service import get_hub_settings
|
||||
email_masking_mode = get_hub_settings().get("email_masking_mode", "none")
|
||||
except Exception:
|
||||
email_masking_mode = "none"
|
||||
|
||||
mode = str(email_masking_mode or "none").strip().lower()
|
||||
|
||||
secret_patterns = [
|
||||
re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE),
|
||||
re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'),
|
||||
|
|
@ -134,6 +143,23 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
|
|||
re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE),
|
||||
]
|
||||
|
||||
email_pattern = re.compile(r'\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b')
|
||||
|
||||
def _mask_email_match(match: re.Match) -> str:
|
||||
local_part = match.group(1)
|
||||
domain_part = match.group(2)
|
||||
if mode == "full":
|
||||
return "***@***.***"
|
||||
elif mode == "partial":
|
||||
if len(local_part) > 2:
|
||||
masked = f"{local_part[0]}***{local_part[-1]}"
|
||||
elif local_part:
|
||||
masked = f"{local_part[0]}***"
|
||||
else:
|
||||
masked = "***"
|
||||
return f"{masked}@{domain_part}"
|
||||
return match.group(0)
|
||||
|
||||
def _mask_str(val: str) -> str:
|
||||
res = val
|
||||
for pat in secret_patterns:
|
||||
|
|
@ -141,6 +167,8 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
|
|||
res = pat.sub(r'\g<1>***', res)
|
||||
elif pat.groups == 1:
|
||||
res = pat.sub(r'***', res)
|
||||
if mode in ("partial", "full"):
|
||||
res = email_pattern.sub(_mask_email_match, res)
|
||||
return res
|
||||
|
||||
def _sanitize(node):
|
||||
|
|
@ -156,6 +184,7 @@ def sanitize_snapshot(snap_dict: Any) -> Any:
|
|||
return node
|
||||
return _sanitize(snap_dict)
|
||||
|
||||
|
||||
@app.get("/api/snapshot")
|
||||
def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
||||
snapshot = HubStateStore.get().get_snapshot()
|
||||
|
|
|
|||
|
|
@ -182,8 +182,15 @@ function initEventListeners() {
|
|||
if (btnApplyUpdate) {
|
||||
btnApplyUpdate.addEventListener('click', () => applyUpdate());
|
||||
}
|
||||
|
||||
// Preflight check listener
|
||||
const btnPreflight = document.getElementById('btn-run-preflight');
|
||||
if (btnPreflight) {
|
||||
btnPreflight.addEventListener('click', () => runPreflightChecks());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── SNAPSHOT INGESTION & MONOTONIC SEQ ──
|
||||
async function fetchSnapshot() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -1313,6 +1320,7 @@ function renderSettingsView() {
|
|||
|
||||
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||
|
||||
if (quotaThresholdSel && s.quota_threshold_percent !== undefined) {
|
||||
|
|
@ -1321,6 +1329,9 @@ function renderSettingsView() {
|
|||
if (quotaActionSel && s.quota_threshold_action) {
|
||||
quotaActionSel.value = s.quota_threshold_action;
|
||||
}
|
||||
if (emailMaskingSel && s.email_masking_mode) {
|
||||
emailMaskingSel.value = s.email_masking_mode;
|
||||
}
|
||||
if (monitorIntervalInput && s.monitoring_interval_seconds !== undefined) {
|
||||
monitorIntervalInput.value = s.monitoring_interval_seconds;
|
||||
}
|
||||
|
|
@ -1329,11 +1340,13 @@ function renderSettingsView() {
|
|||
async function saveHubServerSettings() {
|
||||
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
|
||||
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
|
||||
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
|
||||
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
|
||||
|
||||
const newSettings = {
|
||||
quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0,
|
||||
quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify',
|
||||
email_masking_mode: emailMaskingSel ? emailMaskingSel.value : 'none',
|
||||
monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30,
|
||||
};
|
||||
|
||||
|
|
@ -1347,6 +1360,73 @@ async function saveHubServerSettings() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── PREFLIGHT READINESS CHECKS ──
|
||||
async function runPreflightChecks() {
|
||||
const container = document.getElementById('preflight-results-container');
|
||||
const btn = document.getElementById('btn-run-preflight');
|
||||
if (btn) btn.disabled = true;
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="loading-state" style="padding:12px; font-size:13px; color:var(--text-secondary);">⏳ Запуск zero-quota проверки зависимостей и окружения...</div>';
|
||||
}
|
||||
try {
|
||||
const res = await executeAction('run_preflight', {});
|
||||
if (!res) throw new Error('Сервер не вернул ответ');
|
||||
const report = res.data || {};
|
||||
renderPreflightReport(report, container);
|
||||
showToast(res.message || 'Проверка готовности завершена', res.ok ? 'success' : 'warning');
|
||||
} catch (err) {
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="modal-feedback error">❌ Ошибка выполнения проверки: ${escapeHtml(err.message || String(err))}</div>`;
|
||||
}
|
||||
showToast('Ошибка при запуске проверки готовности', 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreflightReport(report, container) {
|
||||
if (!container) return;
|
||||
const checks = report.checks || [];
|
||||
const passed = report.passed_count || 0;
|
||||
const failed = report.failed_count || 0;
|
||||
const warn = report.warn_count || 0;
|
||||
|
||||
const statusBadge = `<span class="badge ${failed === 0 ? 'healthy' : 'error'}">${failed === 0 ? 'Все проверки пройдены' : `Обнаружено ошибок: ${failed}`}</span>`;
|
||||
|
||||
let html = `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; padding:10px 14px; background:var(--surface-muted); border-radius:var(--radius-sm);">
|
||||
<div style="font-weight:600; font-size:13px;">Результат: ${statusBadge}</div>
|
||||
<div style="font-size:12px; color:var(--text-muted);">
|
||||
Пройдено: <strong style="color:var(--status-healthy);">${passed}</strong> •
|
||||
Ошибок: <strong style="color:var(--status-error);">${failed}</strong> •
|
||||
Предупреждений: <strong style="color:var(--status-warning);">${warn}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preflight-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||
`;
|
||||
|
||||
checks.forEach((item) => {
|
||||
const badgeClass = item.status === 'PASS' ? 'healthy' : (item.status === 'WARN' ? 'warning' : 'error');
|
||||
const icon = item.status === 'PASS' ? '✓' : (item.status === 'WARN' ? '⚠' : '✕');
|
||||
html += `
|
||||
<div class="preflight-item" style="padding:10px 14px; background:var(--surface-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||
<div style="font-weight:600; font-size:13px; display:flex; align-items:center; gap:8px;">
|
||||
<span class="badge ${badgeClass}" style="padding:2px 8px; font-size:11px;">${icon} ${escapeHtml(item.status)}</span>
|
||||
<span>${escapeHtml(item.name || item.check_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-secondary); margin-left:4px;">${escapeHtml(item.message || '')}</div>
|
||||
${item.remediation ? `<div style="font-size:12px; color:var(--status-warning); margin-top:4px; margin-left:4px; font-style:italic;">💡 Рекомендация: ${escapeHtml(item.remediation)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
function initSettings() {
|
||||
const btnSave = document.getElementById('btn-save-client-settings');
|
||||
const tokenInput = document.getElementById('setting-client-token-input');
|
||||
|
|
|
|||
|
|
@ -277,9 +277,23 @@
|
|||
<!-- Readiness state banner rendered by app.js -->
|
||||
</div>
|
||||
|
||||
<div class="section-card" id="health-preflight-card">
|
||||
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div class="section-card-title">Проверка готовности (Preflight / Dependency Agent)</div>
|
||||
<div class="section-card-subtitle">Zero-quota аудит окружения, зависимостей, прав доступа и локальных серверов</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-run-preflight">Запустить проверку готовности</button>
|
||||
</div>
|
||||
<div id="preflight-results-container" style="padding-top:12px;">
|
||||
<!-- Rendered by app.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title">Ресурсы хост-системы</div>
|
||||
|
||||
<div class="section-card-subtitle">Мониторинг CPU, памяти, диска и сетевых параметров</div>
|
||||
</div>
|
||||
<div class="host-resources-grid" id="health-host-resources">
|
||||
|
|
@ -402,6 +416,19 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Маскирование почты (PII)</div>
|
||||
<div class="setting-desc">Уровень маскирования адресов электронной почты в снапшотах и логах</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-email-masking-mode" class="select-filter">
|
||||
<option value="none" selected>Без маскирования (по умолчанию)</option>
|
||||
<option value="partial">Частичное (v***@gmail.com)</option>
|
||||
<option value="full">Полное (***@***.***)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">Тема оформления</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,47 @@ def _slug(value: str) -> str:
|
|||
return result or f"agent-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def sanitize_run_data(node: Any) -> Any:
|
||||
"""Recursively strip or mask any credentials or secret keys from workflow run state."""
|
||||
secret_key_substrings = [
|
||||
"api_key", "token", "password", "secret", "jwt", "bearer",
|
||||
"access_token", "refresh_token", "client_secret", "authorization",
|
||||
]
|
||||
if isinstance(node, dict):
|
||||
sanitized: dict[str, Any] = {}
|
||||
for k, v in node.items():
|
||||
k_lower = str(k).lower()
|
||||
if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"):
|
||||
sanitized[k] = "***"
|
||||
else:
|
||||
sanitized[k] = sanitize_run_data(v)
|
||||
return sanitized
|
||||
elif isinstance(node, list):
|
||||
return [sanitize_run_data(x) for x in node]
|
||||
elif isinstance(node, str):
|
||||
val = node
|
||||
val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE)
|
||||
val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val)
|
||||
val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val)
|
||||
val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE)
|
||||
return val
|
||||
return node
|
||||
|
||||
|
||||
def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||||
"""Return the last saved workflow run state from workflow_run_state.json, if any."""
|
||||
p = run_state_path or paths.get_workflow_run_state_path()
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return sanitize_run_data(data)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]:
|
||||
"""Resolve an Agent File below HERMES_HOME/agents and reject traversal."""
|
||||
root = paths.get_agent_files_dir().resolve()
|
||||
|
|
@ -103,8 +144,9 @@ class WorkflowService:
|
|||
_instance: Optional["WorkflowService"] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self, state_path: Optional[Path] = None) -> None:
|
||||
def __init__(self, state_path: Optional[Path] = None, run_state_path: Optional[Path] = None) -> None:
|
||||
self.state_path = state_path or paths.get_workflow_state_path()
|
||||
self.run_state_path = run_state_path or paths.get_workflow_run_state_path()
|
||||
self._lock = threading.RLock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
|
@ -112,6 +154,7 @@ class WorkflowService:
|
|||
self.workflow = WorkflowDefinition()
|
||||
self.events: list[WorkflowEvent] = []
|
||||
self.run: dict[str, Any] = self._idle_run()
|
||||
self._completed_steps: list[dict[str, Any]] = []
|
||||
self._load()
|
||||
self._migrate_router_roles()
|
||||
|
||||
|
|
@ -140,6 +183,25 @@ class WorkflowService:
|
|||
}
|
||||
|
||||
def _load(self) -> None:
|
||||
# 1. Load run state from workflow_run_state.json
|
||||
if self.run_state_path.is_file():
|
||||
try:
|
||||
run_state = json.loads(self.run_state_path.read_text(encoding="utf-8"))
|
||||
if isinstance(run_state, dict):
|
||||
self._completed_steps = list(run_state.get("completed_steps", []))
|
||||
if run_state.get("status") in {"RUNNING", "running", "STOPPING", "stopping"}:
|
||||
run_state["status"] = "INTERRUPTED"
|
||||
run_state["interruption_reason"] = "Прогон был прерван перезапуском сервера или сбоем процесса"
|
||||
run_state["updated_at"] = _utc_timestamp()
|
||||
sanitized = sanitize_run_data(run_state)
|
||||
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.run_state_path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temp.replace(self.run_state_path)
|
||||
except Exception:
|
||||
self._completed_steps = []
|
||||
|
||||
# 2. Load workflow definition and events from workflow_state.json
|
||||
if not self.state_path.is_file():
|
||||
return
|
||||
try:
|
||||
|
|
@ -164,6 +226,42 @@ class WorkflowService:
|
|||
self.events = []
|
||||
self.run = self._idle_run()
|
||||
|
||||
def _save_run_state(
|
||||
self,
|
||||
status: str,
|
||||
step_index: int = 0,
|
||||
current_agent: Optional[str] = None,
|
||||
iteration: int = 1,
|
||||
completed_step: Optional[dict[str, Any]] = None,
|
||||
interruption_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
if completed_step:
|
||||
self._completed_steps.append(sanitize_run_data(completed_step))
|
||||
|
||||
state_payload = {
|
||||
"run_id": self.run.get("id"),
|
||||
"status": status.upper(),
|
||||
"started_at": self.run.get("started_at"),
|
||||
"updated_at": _utc_timestamp(),
|
||||
"current_step_index": step_index,
|
||||
"current_agent_id": current_agent,
|
||||
"iteration_count": iteration,
|
||||
"completed_steps": list(self._completed_steps),
|
||||
"interruption_reason": interruption_reason,
|
||||
}
|
||||
sanitized = sanitize_run_data(state_payload)
|
||||
try:
|
||||
self.run_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.run_state_path.with_suffix(".tmp")
|
||||
temp.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temp.replace(self.run_state_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_last_run_state(self) -> Optional[dict[str, Any]]:
|
||||
"""Return the last saved run state from workflow_run_state.json."""
|
||||
return get_last_run_state(self.run_state_path)
|
||||
|
||||
def _save(self) -> None:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
|
|
@ -425,6 +523,7 @@ class WorkflowService:
|
|||
if not start_id or start_id not in self.agents:
|
||||
raise ValueError("В workflow нет стартового агента")
|
||||
self._stop.clear()
|
||||
self._completed_steps = []
|
||||
self.run = self._idle_run()
|
||||
self.run.update({
|
||||
"id": uuid.uuid4().hex,
|
||||
|
|
@ -436,6 +535,7 @@ class WorkflowService:
|
|||
})
|
||||
self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1)
|
||||
self._save()
|
||||
self._save_run_state("RUNNING", step_index=0, current_agent=start_id, iteration=1)
|
||||
self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True)
|
||||
self._thread.start()
|
||||
return dict(self.run)
|
||||
|
|
@ -448,6 +548,7 @@ class WorkflowService:
|
|||
self._stop.set()
|
||||
self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning")
|
||||
self._save()
|
||||
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем")
|
||||
return dict(self.run)
|
||||
|
||||
def _execute(self) -> None:
|
||||
|
|
@ -466,10 +567,12 @@ class WorkflowService:
|
|||
visited[current] = visited.get(current, 0) + 1
|
||||
iteration = max(visited.values())
|
||||
self.run.update({"current_agent_id": current, "iteration": iteration})
|
||||
step_idx = len(self._completed_steps)
|
||||
if iteration > self.workflow.max_iterations:
|
||||
message = f"Достигнут предел итераций: {self.workflow.max_iterations}"
|
||||
self.run.update({"status": "failed", "error": message})
|
||||
self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration)
|
||||
self._save_run_state("FAILED", step_index=step_idx, current_agent=current, iteration=iteration, interruption_reason=message)
|
||||
break
|
||||
file_data = self.read_agent_file(current)
|
||||
if not file_data["exists"]:
|
||||
|
|
@ -479,6 +582,7 @@ class WorkflowService:
|
|||
)
|
||||
self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration)
|
||||
self._save()
|
||||
self._save_run_state("RUNNING", step_index=step_idx, current_agent=current, iteration=iteration)
|
||||
request = {
|
||||
"model": self._execution_config(agent).get("model"),
|
||||
"messages": [
|
||||
|
|
@ -515,6 +619,19 @@ class WorkflowService:
|
|||
duration_seconds=duration,
|
||||
error=text if status == "ERROR" else None,
|
||||
)
|
||||
step_summary = {
|
||||
"step_index": step_idx,
|
||||
"agent_id": current,
|
||||
"agent_name": agent.name,
|
||||
"iteration": iteration,
|
||||
"status": status,
|
||||
"duration_seconds": duration,
|
||||
"provider": metadata.get("provider"),
|
||||
"account": metadata.get("profile_id"),
|
||||
"model": metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"),
|
||||
"error": text if status in {"ERROR", "REVIEW_FAILED"} else None,
|
||||
"timestamp": _utc_timestamp(),
|
||||
}
|
||||
edge = next(
|
||||
(item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}),
|
||||
None,
|
||||
|
|
@ -548,6 +665,14 @@ class WorkflowService:
|
|||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._save_run_state(
|
||||
"FAILED" if self.run["status"] == "failed" else "COMPLETED",
|
||||
step_index=step_idx + 1,
|
||||
current_agent=current,
|
||||
iteration=iteration,
|
||||
completed_step=step_summary,
|
||||
interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None,
|
||||
)
|
||||
break
|
||||
self._event(
|
||||
"WORKFLOW_TRANSITION",
|
||||
|
|
@ -562,10 +687,18 @@ class WorkflowService:
|
|||
}, ensure_ascii=False)
|
||||
current = edge.target
|
||||
self._save()
|
||||
self._save_run_state(
|
||||
"RUNNING",
|
||||
step_index=step_idx + 1,
|
||||
current_agent=current,
|
||||
iteration=iteration,
|
||||
completed_step=step_summary,
|
||||
)
|
||||
with self._lock:
|
||||
if self._stop.is_set():
|
||||
self.run.update({"status": "stopped", "error": "Остановлено пользователем"})
|
||||
self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning")
|
||||
self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=current, iteration=iteration, interruption_reason="Остановлено пользователем")
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self.run.update({"status": "failed", "error": str(exc)})
|
||||
|
|
@ -576,6 +709,7 @@ class WorkflowService:
|
|||
EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error")
|
||||
except Exception:
|
||||
pass
|
||||
self._save_run_state("FAILED", step_index=len(self._completed_steps), current_agent=current, iteration=visited.get(current, 1), interruption_reason=str(exc))
|
||||
finally:
|
||||
with self._lock:
|
||||
self.run["finished_at"] = _utc_timestamp()
|
||||
|
|
@ -583,6 +717,7 @@ class WorkflowService:
|
|||
self.run["current_agent_id"] = current or self.run.get("current_agent_id")
|
||||
self._save()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _response_text(response: Any) -> str:
|
||||
if not isinstance(response, dict):
|
||||
|
|
@ -644,3 +779,7 @@ def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]
|
|||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "message": "Выполнено", "data": result}
|
||||
|
||||
|
||||
# Alias for execution service compatibility
|
||||
WorkflowExecutionService = WorkflowService
|
||||
|
|
|
|||
397
tests/test_a31_preflight_state_batching_pii.py
Normal file
397
tests/test_a31_preflight_state_batching_pii.py
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
"""Tests for Task A31: Preflight Dependency Agent, Workflow Run State, Local Concurrency & Context Window, PII Masking, and Cost Controller Honesty.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
|
||||
from antigravity_provider.router.preflight_service import PreflightCheckService, PreflightReport
|
||||
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
|
||||
from antigravity_provider.router.router_config import (
|
||||
RouterConfig,
|
||||
RouterProfileConfig,
|
||||
get_default_router_config,
|
||||
load_router_config,
|
||||
)
|
||||
from antigravity_provider.router.settings_service import (
|
||||
DEFAULT_SETTINGS,
|
||||
get_hub_settings,
|
||||
invalidate_settings_cache,
|
||||
save_hub_settings,
|
||||
)
|
||||
from antigravity_provider.router.telemetry_service import (
|
||||
TelemetryAggregates,
|
||||
TelemetryRecord,
|
||||
TelemetryService,
|
||||
format_token_count,
|
||||
)
|
||||
from antigravity_provider.router.web.server import sanitize_snapshot
|
||||
from antigravity_provider.router.workflow_service import (
|
||||
AgentDefinition,
|
||||
WorkflowDefinition,
|
||||
WorkflowExecutionService,
|
||||
WorkflowService,
|
||||
get_last_run_state,
|
||||
sanitize_run_data,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# P0-1: Preflight Dependency Agent
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_dependency_agent_role_registered():
|
||||
"""Verify 13th role 'dependency-agent' and its canonical aliases in RoleRegistry."""
|
||||
assert len(CANONICAL_ROLES) == 13
|
||||
assert "dependency-agent" in CANONICAL_ROLES
|
||||
|
||||
role_def = CANONICAL_ROLES["dependency-agent"]
|
||||
assert role_def.role_id == "dependency-agent"
|
||||
assert role_def.display_name_ru == "Проверяющий готовность"
|
||||
assert role_def.short_name_ru == "Готовность"
|
||||
assert role_def.is_implemented is True
|
||||
assert "preflight" in role_def.capabilities
|
||||
|
||||
# Test alias resolution
|
||||
aliases = [
|
||||
"dependency-agent",
|
||||
"dependency_agent",
|
||||
"preflight",
|
||||
"проверяющий готовность",
|
||||
"агент зависимостей",
|
||||
"готовность",
|
||||
"dependency",
|
||||
]
|
||||
for alias in aliases:
|
||||
canonical = RoleRegistry.resolve_role_name(alias)
|
||||
assert canonical == "dependency-agent", f"Alias '{alias}' resolved to '{canonical}'"
|
||||
|
||||
|
||||
def test_preflight_service_cli_and_environment():
|
||||
"""Verify CLI tools and environment checks."""
|
||||
service = PreflightCheckService.get()
|
||||
|
||||
cli_items = service.check_cli_dependencies()
|
||||
assert len(cli_items) >= 3
|
||||
ids = {item.check_id for item in cli_items}
|
||||
assert "cli_agy" in ids
|
||||
assert "pkg_fastapi" in ids
|
||||
assert "pkg_uvicorn" in ids
|
||||
|
||||
env_items = service.check_system_environment()
|
||||
assert len(env_items) >= 3
|
||||
env_ids = {item.check_id for item in env_items}
|
||||
assert "env_hermes_home" in env_ids
|
||||
assert "env_config_writable" in env_ids
|
||||
assert "env_logs_writable" in env_ids
|
||||
|
||||
|
||||
def test_preflight_service_run_all_and_action():
|
||||
"""Verify run_all_checks and action execution."""
|
||||
service = PreflightCheckService.get()
|
||||
report = service.run_all_checks()
|
||||
|
||||
assert isinstance(report, PreflightReport)
|
||||
assert isinstance(report.passed_count, int)
|
||||
assert isinstance(report.failed_count, int)
|
||||
assert isinstance(report.warn_count, int)
|
||||
assert len(report.checks) > 0
|
||||
|
||||
report_dict = report.to_dict()
|
||||
assert "success" in report_dict
|
||||
assert "checks" in report_dict
|
||||
assert isinstance(report_dict["checks"], list)
|
||||
|
||||
# Test via ActionExecutor
|
||||
action_res = ActionExecutor.execute("run_preflight", {})
|
||||
assert "ok" in action_res
|
||||
assert "message" in action_res
|
||||
assert "data" in action_res
|
||||
assert "checks" in action_res["data"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# P0-2: Workflow Run State Manager
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_workflow_run_state_sanitization():
|
||||
"""Verify recursive secret stripping in workflow run state."""
|
||||
raw_state = {
|
||||
"run_id": "test-run-123",
|
||||
"status": "RUNNING",
|
||||
"api_key": "sk-1234567890abcdef",
|
||||
"token": "gho_secrettoken123456",
|
||||
"nested": {
|
||||
"password": "supersecretpass",
|
||||
"auth_status": "ok",
|
||||
"message": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0",
|
||||
"extra_url": "https://example.com/callback?access_token=secret12345&foo=bar",
|
||||
},
|
||||
"step_list": [
|
||||
{"account": "acc-1", "client_secret": "my-client-secret-123"},
|
||||
{"safe_field": "public_data"},
|
||||
],
|
||||
}
|
||||
|
||||
sanitized = sanitize_run_data(raw_state)
|
||||
|
||||
assert sanitized["api_key"] == "***"
|
||||
assert sanitized["token"] == "***"
|
||||
assert sanitized["nested"]["password"] == "***"
|
||||
assert sanitized["nested"]["auth_status"] == "ok"
|
||||
assert "Bearer ***" in sanitized["nested"]["message"]
|
||||
assert "access_token=***" in sanitized["nested"]["extra_url"]
|
||||
assert sanitized["step_list"][0]["client_secret"] == "***"
|
||||
assert sanitized["step_list"][1]["safe_field"] == "public_data"
|
||||
|
||||
|
||||
def test_workflow_run_state_interrupted_on_startup(tmp_path: Path):
|
||||
"""Verify that a RUNNING state in workflow_run_state.json transitions to INTERRUPTED on reload."""
|
||||
state_file = tmp_path / "workflow_state.json"
|
||||
run_state_file = tmp_path / "workflow_run_state.json"
|
||||
|
||||
# Pre-populate run state with RUNNING status
|
||||
initial_run_state = {
|
||||
"run_id": "run-crash-test",
|
||||
"status": "RUNNING",
|
||||
"started_at": "2026-08-26T00:00:00Z",
|
||||
"updated_at": "2026-08-26T00:00:00Z",
|
||||
"current_step_index": 2,
|
||||
"current_agent_id": "developer-1",
|
||||
"iteration_count": 1,
|
||||
"completed_steps": [
|
||||
{"step_index": 0, "agent_id": "manager", "status": "SUCCESS"},
|
||||
{"step_index": 1, "agent_id": "developer-1", "status": "WORKING"},
|
||||
],
|
||||
"interruption_reason": None,
|
||||
}
|
||||
run_state_file.write_text(json.dumps(initial_run_state), encoding="utf-8")
|
||||
|
||||
# Initialize WorkflowService
|
||||
service = WorkflowService(state_path=state_file, run_state_path=run_state_file)
|
||||
|
||||
# Check that state transitioned to INTERRUPTED
|
||||
last_state = service.get_last_run_state()
|
||||
assert last_state is not None
|
||||
assert last_state["status"] == "INTERRUPTED"
|
||||
assert last_state["interruption_reason"] == "Прогон был прерван перезапуском сервера или сбоем процесса"
|
||||
assert len(last_state["completed_steps"]) == 2
|
||||
|
||||
# Verify top-level function
|
||||
assert get_last_run_state(run_state_file)["status"] == "INTERRUPTED"
|
||||
|
||||
|
||||
def test_workflow_execution_service_alias():
|
||||
"""Verify WorkflowExecutionService is an alias of WorkflowService."""
|
||||
assert WorkflowExecutionService is WorkflowService
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# P0-3: Local Concurrency & Context Window
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_local_profile_max_concurrency_is_one():
|
||||
"""Verify all local provider profiles have max_concurrency = 1."""
|
||||
config = get_default_router_config()
|
||||
for pid, pcfg in config.profiles.items():
|
||||
if pcfg.provider == "local":
|
||||
assert pcfg.max_concurrency == 1, f"Local profile {pid} has max_concurrency={pcfg.max_concurrency}"
|
||||
|
||||
# Verify loaded config also enforces max_concurrency = 1 for local profiles
|
||||
loaded = load_router_config()
|
||||
for pid, pcfg in loaded.profiles.items():
|
||||
if pcfg.provider == "local":
|
||||
assert pcfg.max_concurrency == 1
|
||||
|
||||
|
||||
def test_local_adapter_get_context_window():
|
||||
"""Verify LocalLLMAdapter retrieves context window accurately without hallucinating defaults."""
|
||||
adapter = LocalLLMAdapter()
|
||||
|
||||
# Profile with explicit context_window in auth_config
|
||||
prof_with_cfg = RouterProfileConfig(
|
||||
profile_id="local-test-1",
|
||||
provider="local",
|
||||
account_id="acc-1",
|
||||
auth_config={"context_window": 8192},
|
||||
)
|
||||
assert adapter.get_context_window(prof_with_cfg) == 8192
|
||||
|
||||
# Profile without context length and with non-responding server
|
||||
prof_empty = RouterProfileConfig(
|
||||
profile_id="local-test-2",
|
||||
provider="local",
|
||||
account_id="acc-2",
|
||||
custom_base_url="http://127.0.0.1:9999/v1",
|
||||
)
|
||||
# Must return None instead of inventing fake numbers
|
||||
assert adapter.get_context_window(prof_empty) is None
|
||||
|
||||
|
||||
def test_local_adapter_context_truncation_guard():
|
||||
"""Verify context truncation guard protects against VRAM overflow when context_window is known."""
|
||||
adapter = LocalLLMAdapter()
|
||||
|
||||
prof = RouterProfileConfig(
|
||||
profile_id="local-small-ctx",
|
||||
provider="local",
|
||||
account_id="acc-1",
|
||||
auth_config={"context_window": 500},
|
||||
custom_base_url="http://127.0.0.1:12345/v1",
|
||||
)
|
||||
|
||||
# Huge prompt exceeding 500 tokens
|
||||
long_middle_content = "important historical dialogue step " * 100
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Initial prompt 1"},
|
||||
{"role": "assistant", "content": long_middle_content},
|
||||
{"role": "user", "content": "Initial prompt 2"},
|
||||
{"role": "assistant", "content": long_middle_content},
|
||||
{"role": "user", "content": "Latest user task to execute."},
|
||||
]
|
||||
|
||||
mock_resp = {
|
||||
"choices": [{"message": {"role": "assistant", "content": "Truncated prompt executed successfully."}}],
|
||||
"usage": {"prompt_tokens": 200, "completion_tokens": 10},
|
||||
}
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.read.return_value = json.dumps(mock_resp).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_cm
|
||||
|
||||
res = adapter.invoke(prof, {"messages": messages, "max_tokens": 100})
|
||||
assert res["choices"][0]["message"]["content"] == "Truncated prompt executed successfully."
|
||||
|
||||
# Verify sent payload messages were truncated
|
||||
args, kwargs = mock_urlopen.call_args
|
||||
sent_req = args[0]
|
||||
sent_body = json.loads(sent_req.data.decode("utf-8"))
|
||||
sent_messages = sent_body["messages"]
|
||||
|
||||
assert sent_messages[0]["role"] == "system"
|
||||
assert sent_messages[-1]["content"] == "Latest user task to execute."
|
||||
# Total count of messages should be pruned
|
||||
assert len(sent_messages) < len(messages)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# P0-4: PII Email Masking
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_settings_email_masking_mode():
|
||||
"""Verify email_masking_mode in default settings and persistence."""
|
||||
assert DEFAULT_SETTINGS["email_masking_mode"] == "none"
|
||||
|
||||
settings = get_hub_settings()
|
||||
assert settings.get("email_masking_mode") in ("none", "partial", "full")
|
||||
|
||||
|
||||
def test_sanitize_snapshot_email_masking_modes():
|
||||
"""Verify email masking behavior across 'none', 'partial', and 'full' modes."""
|
||||
snapshot_data = {
|
||||
"user_email": "vasya.pupkin@example.com",
|
||||
"account_id": "google-user-1",
|
||||
"api_key": "sk-secret123456789",
|
||||
"nested": {
|
||||
"developer": "developer.one@domain.org",
|
||||
"reviewer": "r@test.com",
|
||||
},
|
||||
}
|
||||
|
||||
# 1. Mode: none (emails unchanged, secrets masked)
|
||||
san_none = sanitize_snapshot(snapshot_data, email_masking_mode="none")
|
||||
assert san_none["user_email"] == "vasya.pupkin@example.com"
|
||||
assert san_none["nested"]["developer"] == "developer.one@domain.org"
|
||||
assert san_none["nested"]["reviewer"] == "r@test.com"
|
||||
assert "api_key" not in san_none
|
||||
|
||||
# 2. Mode: partial (preserves first and last char of local part + domain for differentiation)
|
||||
san_partial = sanitize_snapshot(snapshot_data, email_masking_mode="partial")
|
||||
assert san_partial["user_email"] == "v***n@example.com"
|
||||
assert san_partial["nested"]["developer"] == "d***e@domain.org"
|
||||
assert san_partial["nested"]["reviewer"] == "r***@test.com"
|
||||
assert "api_key" not in san_partial
|
||||
|
||||
# 3. Mode: full (***@***.***)
|
||||
san_full = sanitize_snapshot(snapshot_data, email_masking_mode="full")
|
||||
assert san_full["user_email"] == "***@***.***"
|
||||
assert san_full["nested"]["developer"] == "***@***.***"
|
||||
assert san_full["nested"]["reviewer"] == "***@***.***"
|
||||
assert "api_key" not in san_full
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# P0-5: Cost Controller Token Honesty
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_telemetry_measured_vs_estimated_tokens(tmp_path: Path):
|
||||
"""Verify telemetry distinguishes measured exact tokens from estimated tokens with ~."""
|
||||
log_file = tmp_path / "telemetry_test.jsonl"
|
||||
service = TelemetryService(log_path=log_file)
|
||||
|
||||
# 1. Record measured call
|
||||
rec1 = service.record_call(
|
||||
role="developer-1",
|
||||
profile_id="ag-w1",
|
||||
provider="antigravity",
|
||||
model="claude-3-7-sonnet",
|
||||
outcome="success",
|
||||
latency_seconds=1.25,
|
||||
prompt_tokens_measured=500,
|
||||
completion_tokens_measured=150,
|
||||
is_estimated=False,
|
||||
)
|
||||
assert rec1.prompt_tokens_measured == 500
|
||||
assert rec1.prompt_tokens_estimated is None
|
||||
assert rec1.is_estimated is False
|
||||
assert rec1.total_tokens == 650
|
||||
|
||||
# 2. Record estimated call
|
||||
rec2 = service.record_call(
|
||||
role="tester",
|
||||
profile_id="local-1",
|
||||
provider="local",
|
||||
model="Qwen3.8-27B-Q4_K_M.gguf",
|
||||
outcome="success",
|
||||
latency_seconds=0.85,
|
||||
prompt_tokens_estimated=300,
|
||||
completion_tokens_estimated=50,
|
||||
is_estimated=True,
|
||||
)
|
||||
assert rec2.prompt_tokens_measured is None
|
||||
assert rec2.prompt_tokens_estimated == 300
|
||||
assert rec2.is_estimated is True
|
||||
assert rec2.total_tokens == 350
|
||||
|
||||
# 3. Aggregates for measured only
|
||||
agg_measured = service.get_aggregates(profile_id="ag-w1")
|
||||
assert agg_measured.total_tokens_measured == 650
|
||||
assert agg_measured.tokens_display == "650"
|
||||
assert agg_measured.has_estimated_tokens is False
|
||||
|
||||
# 4. Aggregates for estimated only
|
||||
agg_est = service.get_aggregates(profile_id="local-1")
|
||||
assert agg_est.total_tokens_estimated == 350
|
||||
assert agg_est.tokens_display == "~350"
|
||||
assert agg_est.has_estimated_tokens is True
|
||||
|
||||
|
||||
def test_format_token_count():
|
||||
"""Verify format_token_count formatting helper."""
|
||||
assert format_token_count(1250, None) == "1250"
|
||||
assert format_token_count(None, 1250) == "~1250"
|
||||
assert format_token_count(1000, 250) == "1000"
|
||||
assert format_token_count(None, None) is None
|
||||
Loading…
Reference in a new issue