diff --git a/agents/inbox/2026-08-26-antigravity-release-gate-a30.md b/agents/inbox/2026-08-26-antigravity-release-gate-a30.md new file mode 100644 index 0000000..67c836f --- /dev/null +++ b/agents/inbox/2026-08-26-antigravity-release-gate-a30.md @@ -0,0 +1,43 @@ +# Задание Antigravity: полный release gate после A30 + +## Цель + +Провести независимую проверку ветки `codex/workflow-canvas` после реализации A30. Проверять фактическое состояние репозитория и запускаемого приложения, а не описание работы. + +## Обязательный порядок + +1. Получить актуальные `origin/main` и `origin/codex/workflow-canvas`. +2. Проверить `git status`, базовый и финальный SHA ветки. +3. Запустить приложение из чистого checkout ветки A30. +4. Выполнить полный `pytest`/release gate и сохранить полный вывод. +5. Выполнить `ruff check .`. +6. Проверить веб-контракт: `/`, `/api/snapshot`, `/api/events`, `/api/action`. +7. Проверить A30 вручную в браузере: LIVE, EDIT, создание агента, назначение Provider → Account → Model, Agent File, редактор ребра, цикл и предел итераций. +8. Отдельно проверить честность данных: отсутствие mock/demo чисел из макета, `Н/Д` с причиной, loading не смешан с отсутствием данных. +9. Проверить persistence после перезапуска и реальные provider errors. +10. Проверить, что desktop `router/ui/**` не изменён A30. + +## Правила отчёта + +- Не писать `PASS`, если полный release gate не запускался. +- Не считать targeted tests заменой полного regression. +- Для каждого failure привести команду, stdout/stderr, файл и минимальный способ воспроизведения. +- Если блокер связан с окружением, повторить проверку в чистом окружении или явно указать, что именно не проверено. + +## Артефакты + +Передать: + +- `START_HEAD`, `FINAL_HEAD`, `origin/main`; +- чистый `git status` или полный список загрязнений; +- `X passed / Y skipped / Z failed`; +- точный результат `scripts/release_gate.py`; +- список найденных дефектов с приоритетом P0–P3; +- скриншоты LIVE, EDIT, Inspector, Agent File и редактора ребра; +- отдельный список пропущенных проверок. + +## Ограничения + +- Ничего не исправлять молча в чужой ветке: найденные дефекты оформить отдельным патчем/коммитом или вернуть владельцу. +- Не удалять пользовательские изменения в установщике, бинарниках и заданиях inbox. +- Не объявлять release-ready при известных блокерах. diff --git a/docs/screenshots/a30/agent-file-editor.png b/docs/screenshots/a30/agent-file-editor.png new file mode 100644 index 0000000..3c6d244 Binary files /dev/null and b/docs/screenshots/a30/agent-file-editor.png differ diff --git a/docs/screenshots/a30/edge-editor.png b/docs/screenshots/a30/edge-editor.png new file mode 100644 index 0000000..ff7a881 Binary files /dev/null and b/docs/screenshots/a30/edge-editor.png differ diff --git a/docs/screenshots/a30/overview-edit-inspector.png b/docs/screenshots/a30/overview-edit-inspector.png new file mode 100644 index 0000000..3b820f1 Binary files /dev/null and b/docs/screenshots/a30/overview-edit-inspector.png differ diff --git a/docs/screenshots/a30/overview-live-provider-error.png b/docs/screenshots/a30/overview-live-provider-error.png new file mode 100644 index 0000000..d6410c4 Binary files /dev/null and b/docs/screenshots/a30/overview-live-provider-error.png differ diff --git a/docs/screenshots/a30/overview-live.png b/docs/screenshots/a30/overview-live.png new file mode 100644 index 0000000..206011f Binary files /dev/null and b/docs/screenshots/a30/overview-live.png differ diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 68579fc..542088b 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -90,6 +90,24 @@ readiness, agents, providers, routing, quotas, metrics, is_stale Имена действий берутся **ровно** из общего слоя `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` | Запросить остановку текущего запуска | — | + +Состояние графа и 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 diff --git a/src/antigravity_provider/paths.py b/src/antigravity_provider/paths.py index 124869f..5088675 100644 --- a/src/antigravity_provider/paths.py +++ b/src/antigravity_provider/paths.py @@ -85,6 +85,23 @@ def get_router_active_profile_path() -> Path: return get_config_dir() / "router_active_profile.json" +def get_workflow_state_path() -> Path: + """Return the persisted agent/workflow state sidecar. + + Logical roles and their execution routes remain canonical in + ``router_profiles.yaml``. This file stores only the extra agent metadata, + graph layout and execution checkpoints which do not belong to routing. + """ + return get_config_dir() / "workflow_state.json" + + +def get_agent_files_dir() -> Path: + """Return the user-editable directory containing real Agent Files.""" + directory = get_hermes_home() / "agents" + directory.mkdir(parents=True, exist_ok=True) + return directory + + def get_compatibility_path() -> Path: return get_config_dir() / "compatibility.json" diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 1366dc8..d9f2aba 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -305,6 +305,23 @@ class ActionExecutor: except Exception: pass + if action in { + 'create_agent', + 'update_agent', + 'delete_agent', + 'read_agent_file', + 'save_agent_file', + 'save_workflow', + 'start_workflow', + 'stop_workflow', + }: + try: + from antigravity_provider.router.workflow_service import execute_workflow_action + + return execute_workflow_action(action, data) + except (ValueError, OSError) as exc: + return {'ok': False, 'message': str(exc)} + # Device-flow для Grok и Codex через веб. Backend был готов давно, но # наружу не выведен: веб-мастер показывал заглушку «не реализовано», и # подключить эти провайдеры можно было только из десктопа. Настоящие diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index 2c3550f..2cb1ac4 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -55,6 +55,7 @@ class HubSnapshot: quotas: Dict[str, Any] metrics: Dict[str, Any] = field(default_factory=dict) is_stale: bool = False + workflow: Dict[str, Any] = field(default_factory=dict) def get_profile(self, profile_id: str) -> Optional[ProfileViewModel]: return self.all_profiles.get(profile_id) @@ -107,6 +108,24 @@ class HubStateStore: if self._current_snapshot is not None: if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale: self._current_snapshot = replace(self._current_snapshot, is_stale=True) + # Provider/account scans are intentionally cached, while LIVE + # workflow checkpoints are small local state and must never lag + # behind an action until the next expensive provider refresh. + try: + from .workflow_service import WorkflowService + + live_workflow = WorkflowService.get().snapshot() + role_views = {agent.role_id: agent for agent in self._current_snapshot.agents} + for workflow_agent in live_workflow.get("agents", []): + role_view = role_views.get(workflow_agent.get("role")) + generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title() + if role_view and workflow_agent.get("name") == generic_name: + workflow_agent["name"] = role_view.role_name_ru + if role_view and not workflow_agent.get("description"): + workflow_agent["description"] = role_view.role_description_ru + self._current_snapshot = replace(self._current_snapshot, workflow=live_workflow) + except Exception: + pass return self._current_snapshot return self.refresh(force_scan=False) @@ -196,6 +215,27 @@ class HubStateStore: "active_calls_total": active_leases_total, "active_calls_by_profile": active_leases_by_profile, } + try: + from .workflow_service import WorkflowService + + workflow_data = WorkflowService.get().snapshot() + role_views = {agent.role_id: agent for agent in agents} + for workflow_agent in workflow_data.get("agents", []): + role_view = role_views.get(workflow_agent.get("role")) + generic_name = str(workflow_agent.get("role") or "").replace("-", " ").title() + if role_view and workflow_agent.get("name") == generic_name: + workflow_agent["name"] = role_view.role_name_ru + if role_view and not workflow_agent.get("description"): + workflow_agent["description"] = role_view.role_description_ru + except Exception as exc: + workflow_data = { + "agents": [], + "definition": {}, + "run": {"status": "unavailable"}, + "events": [], + "is_loading": False, + "unavailable_reason": f"Workflow state unavailable: {exc}", + } snapshot = HubSnapshot( generation=gen, seq=request_seq, @@ -209,6 +249,7 @@ class HubStateStore: quotas=quotas_map, metrics=metrics, is_stale=False, + workflow=workflow_data, ) self._current_snapshot = snapshot @@ -263,6 +304,13 @@ class HubStateStore: "active_calls_by_profile": {}, }, is_stale=True, + workflow={ + "agents": [], + "definition": {}, + "run": {"status": "loading"}, + "events": [], + "is_loading": True, + }, ) def _apply_profile_delta(self, profile: ProfileViewModel) -> HubSnapshot: diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index e97bfd8..9b42033 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -519,7 +519,7 @@ function updateGlobalHeader() { const isHealthy = readiness.state === 'healthy'; const readyRoles = readiness.roles_ready_count || 0; - const totalRoles = readiness.total_roles || 6; + const totalRoles = readiness.total_roles ?? 0; if (elements.headerReadinessBadge) { elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`; @@ -527,7 +527,7 @@ function updateGlobalHeader() { if (elements.headerReadinessText) { elements.headerReadinessText.textContent = readiness.title_ru ? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)` - : 'Система готова'; + : 'Н/Д: состояние ещё не измерено'; } const kpiReadiness = document.getElementById('kpi-system-readiness'); @@ -805,6 +805,10 @@ function renderQuotaCell(bucket, unavailableReason) { // 1. OVERVIEW VIEW (P0-3, P0-4 Diagram Model Select & Counters) // ═══════════════════════════════════════════════════════════════ function renderOverviewView() { + if (typeof renderWorkflowOverview === 'function') { + renderWorkflowOverview(currentSnapshot); + return; + } if (!currentSnapshot) return; const providers = currentSnapshot.providers || []; diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index d9cf4a5..ec1aa73 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -5,6 +5,7 @@ Hermes Hub — Панель управления + @@ -92,52 +93,53 @@
-
-
-
Состояние системы
-
-
+
+
+ +
-
-
Подключено аккаунтов
-
-
-
-
-
Готовых ролей
-
-
-
-
-
Провайдеры ИИ
-
-
5 поддерживаемых систем
+
+ Сохранено + +
-
-
-
-
Схема маршрутизации запросов
-
Распределение агентов и цепочки отказоустойчивости
+
+
Активные задачиЗагрузка…Получение снапшота
+
Агенты онлайнЗагрузка…Получение снапшота
+
Среднее время ответаЗагрузка…Получение телеметрии
+
Использование токеновЗагрузка…Получение телеметрии
+
Успешность задачЗагрузка…Получение телеметрии
+
+ +
+
+
+
Workflow Загрузка…
+ +
100%
+
+
+ +
+ +
- -
-
- -
+
+ ОжидаетРаботаетПроверяетОшибкаЗавершено + → Успех⇢ Возврат +
+
+ +
-
-
-
Провайдеры и доступность runtime
-
Статус локальных адаптеров и обнаруженные модели
-
-
- -
+
+
Последние события

Загрузка событий…

+
Управление LIVE

@@ -505,5 +507,6 @@ + diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css new file mode 100644 index 0000000..7c00f01 --- /dev/null +++ b/src/antigravity_provider/router/web/static/workflow.css @@ -0,0 +1,89 @@ +/* A30 overview/workflow components. Theme colors come from the shared tokens. */ +#view-overview { padding: 0; min-width: 860px; } +.workflow-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; } +.workflow-mode { display:flex; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-muted); } +.workflow-mode-btn { min-width:78px; border:0; border-radius:var(--radius-sm); padding:7px 16px; color:var(--text-muted); background:transparent; cursor:pointer; font-weight:700; } +.workflow-mode-btn.active { color:var(--text-primary); background:var(--surface-active); box-shadow:inset 0 0 0 1px var(--border-accent); } +.workflow-mode-btn:first-child.active { color:var(--status-healthy); } +.workflow-toolbar-actions { display:flex; gap:8px; align-items:center; } +.workflow-save-state { color:var(--text-muted); font-size:11px; } +.workflow-save-state.dirty { color:var(--status-warning); } +.workflow-kpis { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:8px; margin-bottom:10px; } +.workflow-kpi { padding:11px 13px; min-height:78px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:linear-gradient(145deg,var(--surface),var(--surface-muted)); } +.workflow-kpi span,.workflow-kpi small { display:block; color:var(--text-muted); } +.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:20px; font-weight:650; color:var(--text-primary); } +.workflow-kpi small { font-size:10px; line-height:1.25; } +.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:520px; } +.workflow-board-card,.workflow-inspector,.workflow-events,.workflow-run-panel { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; } +.workflow-board-header { min-height:45px; padding:8px 12px; display:flex; gap:18px; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); } +.workflow-board-header strong { color:var(--text-accent); text-transform:uppercase; } +.workflow-board-header label { margin-left:auto; font-size:11px; color:var(--text-muted); } +.workflow-board-header input { width:42px; padding:3px; color:var(--text-primary); background:var(--surface-muted); border:1px solid var(--border); border-radius:3px; } +.workflow-run-state { margin-left:8px; font-size:11px; color:var(--text-muted); } +.workflow-run-state.running { color:var(--status-healthy); } +.workflow-run-state.failed,.workflow-run-state.interrupted { color:var(--status-error); } +.workflow-zoom { display:flex; align-items:center; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } +.workflow-zoom button { width:28px; height:27px; border:0; border-left:1px solid var(--border-subtle); color:var(--text-accent); background:transparent; cursor:pointer; } +.workflow-zoom span { min-width:44px; text-align:center; font-size:11px; } +.workflow-canvas { position:relative; height:430px; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; } +.workflow-edges,.workflow-node-layer { position:absolute; inset:0; width:100%; height:100%; transform-origin:0 0; } +.workflow-edges { overflow:visible; pointer-events:none; } +.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); } +.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); } +.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; } +.workflow-edge-label { fill:var(--text-muted); font:9px var(--font-ui); text-transform:uppercase; } +.workflow-edge-hit { fill:none; stroke:transparent; stroke-width:14; pointer-events:stroke; cursor:pointer; } +.workflow-node { position:absolute; width:190px; min-height:92px; padding:11px 12px; border:1px solid var(--border-accent); border-radius:9px; background:linear-gradient(145deg,var(--surface),var(--surface-muted)); box-shadow:0 8px 22px rgba(0,0,0,.18); cursor:pointer; user-select:none; } +.workflow-node.selected { outline:2px solid var(--accent); box-shadow:0 0 18px var(--accent-dim); } +.workflow-node.working { border-color:var(--status-healthy); box-shadow:0 0 20px rgba(114,201,67,.24); } +.workflow-node.error { border-color:var(--status-error); } +.workflow-node.completed { border-color:var(--status-disabled); } +.workflow-node h3 { margin:0 0 2px; font-size:13px; font-weight:650; color:var(--text-primary); } +.workflow-node p { overflow:hidden; margin:1px 0; color:var(--text-muted); font-size:10px; white-space:nowrap; text-overflow:ellipsis; } +.workflow-node-status { display:flex; align-items:center; gap:5px; margin-top:6px; font-size:10px; color:var(--text-secondary); } +.workflow-node-status i,.workflow-legend i { width:7px; height:7px; border-radius:50%; background:var(--status-info); } +.workflow-node.working .workflow-node-status i,.state-working { background:var(--status-healthy)!important; } +.workflow-node.reviewing .workflow-node-status i,.state-reviewing { background:var(--status-warning)!important; } +.workflow-node.error .workflow-node-status i,.state-error { background:var(--status-error)!important; } +.workflow-node.completed .workflow-node-status i,.state-completed { background:var(--status-disabled)!important; } +.workflow-port { display:none; position:absolute; top:37px; width:13px; height:22px; border:1px solid var(--accent); border-radius:8px; background:var(--surface-active); } +.workflow-mode-edit .workflow-port { display:block; } +.workflow-port.in { left:-8px; } +.workflow-port.out { right:-8px; cursor:crosshair; } +.workflow-legend { min-height:39px; padding:8px 12px; display:flex; align-items:center; gap:14px; flex-wrap:wrap; border-top:1px solid var(--border-subtle); color:var(--text-muted); font-size:10px; } +.workflow-legend span { display:flex; align-items:center; gap:5px; } +.edge-legend.success { color:var(--status-healthy); }.edge-legend.return { color:var(--status-error); } +.workflow-minimap { position:absolute; left:10px; bottom:10px; width:110px; height:68px; border:1px solid var(--border); background:rgba(6,25,22,.82); pointer-events:none; } +.workflow-minimap i { position:absolute; width:16px; height:9px; border:1px solid var(--accent); background:var(--surface-muted); } +.workflow-empty { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:6px; color:var(--text-muted); } +.workflow-inspector { min-width:0; padding:12px; overflow:auto; max-height:520px; } +.workflow-inspector-empty { height:100%; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:8px; color:var(--text-muted); } +.workflow-inspector h2 { margin:0; color:var(--text-accent); font:600 15px var(--font-title); } +.inspector-tabs { display:flex; gap:2px; margin:10px 0; border-bottom:1px solid var(--border-subtle); overflow-x:auto; } +.inspector-tabs button { padding:7px 6px; border:0; border-bottom:2px solid transparent; color:var(--text-muted); background:transparent; font-size:10px; cursor:pointer; } +.inspector-tabs button.active { color:var(--text-accent); border-color:var(--accent); } +.inspector-field { display:block; margin:9px 0; color:var(--text-muted); font-size:10px; } +.inspector-field input,.inspector-field select,.inspector-field textarea,.workflow-run-panel textarea,.workflow-dialog input,.workflow-dialog select,.workflow-dialog textarea { width:100%; margin-top:4px; padding:7px 8px; color:var(--text-primary); border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--surface-muted); font:12px var(--font-ui); } +.inspector-field textarea { min-height:58px; resize:vertical; } +.inspector-value { padding:7px 0; color:var(--text-secondary); font-size:11px; overflow-wrap:anywhere; } +.inspector-actions { display:flex; gap:7px; flex-wrap:wrap; margin-top:12px; } +.agent-file-card { margin-top:12px; padding:10px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } +.agent-file-card code { display:block; margin:4px 0 8px; color:var(--text-secondary); overflow-wrap:anywhere; } +.workflow-bottom-grid { display:grid; grid-template-columns:1.4fr 1fr; gap:10px; margin-top:10px; } +.workflow-events,.workflow-run-panel { min-height:145px; padding:11px; } +.workflow-events header,.workflow-run-panel header { display:flex; justify-content:space-between; margin-bottom:8px; color:var(--text-secondary); } +.workflow-events header button { border:0; color:var(--text-accent); background:none; cursor:pointer; } +.workflow-events-list { max-height:110px; overflow:auto; } +.workflow-event { display:grid; grid-template-columns:72px 1fr auto; gap:8px; padding:5px 0; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:10px; } +.workflow-event time,.workflow-event em { color:var(--text-muted); font-style:normal; } +.workflow-event.error { color:var(--status-error); } +.workflow-run-panel textarea { min-height:68px; resize:vertical; } +.workflow-run-panel>div { display:flex; gap:8px; margin-top:8px; } +.workflow-run-error { margin-top:6px; color:var(--status-error); font-size:10px; } +.workflow-dialog-backdrop { position:fixed; inset:0; z-index:1200; display:flex; justify-content:center; align-items:center; padding:24px; background:var(--bg-modal-backdrop); } +.workflow-dialog { width:min(580px,100%); max-height:88vh; overflow:auto; padding:18px; border:1px solid var(--border-accent); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 24px 70px rgba(0,0,0,.45); } +.workflow-dialog h2 { margin-bottom:12px; color:var(--text-accent); } +.workflow-dialog textarea.agent-file-editor { min-height:380px; font-family:var(--font-mono); } +.workflow-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; } +.hidden { display:none!important; } +@media (max-width:1200px) { .workflow-kpis { grid-template-columns:repeat(3,1fr); }.workflow-main-layout { grid-template-columns:minmax(560px,1fr) 290px; } } diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js new file mode 100644 index 0000000..334141a --- /dev/null +++ b/src/antigravity_provider/router/web/static/workflow.js @@ -0,0 +1,497 @@ +/* Hermes Hub A30 workflow canvas — vanilla JS, no build step. */ +'use strict'; + +const workflowUi = { + initialized: false, + mode: 'live', + selectedAgentId: null, + selectedTab: 'main', + scale: 1, + dirty: false, + draftEdges: [], + draftPositions: {}, + connectingFrom: null, + drag: null, +}; + +function wfEscape(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ + '&': '&', '<': '<', '>': '>', "'": ''', '"': '"', + })[char]); +} + +function wfValue(value, suffix = '') { + return value === null || value === undefined ? null : `${value}${suffix}`; +} + +function wfUnavailable(elementId, reason) { + const value = document.getElementById(elementId); + const detail = document.getElementById(`${elementId}-reason`); + if (value) value.textContent = 'Н/Д'; + if (detail) detail.textContent = `Н/Д: ${reason}`; +} + +function initWorkflowOverview() { + if (workflowUi.initialized) return; + workflowUi.initialized = true; + document.getElementById('workflow-mode-live')?.addEventListener('click', () => setWorkflowMode('live')); + document.getElementById('workflow-mode-edit')?.addEventListener('click', () => setWorkflowMode('edit')); + document.getElementById('btn-workflow-save')?.addEventListener('click', saveWorkflowDraft); + document.getElementById('btn-agent-add')?.addEventListener('click', openAgentCreateDialog); + document.getElementById('btn-workflow-start')?.addEventListener('click', startWorkflowRun); + document.getElementById('btn-workflow-stop')?.addEventListener('click', () => executeAction('stop_workflow', {})); + document.getElementById('workflow-events-all')?.addEventListener('click', () => switchView('logs')); + document.getElementById('workflow-max-iterations')?.addEventListener('change', markWorkflowDirty); + document.getElementById('workflow-zoom-in')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale + 0.1)); + document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1)); + document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph); + const canvas = document.getElementById('workflow-canvas'); + canvas?.addEventListener('mousemove', workflowPointerMove); + canvas?.addEventListener('mouseup', workflowPointerUp); + canvas?.addEventListener('mouseleave', workflowPointerCancel); + window.addEventListener('resize', drawWorkflowEdges); +} + +function renderWorkflowOverview(snapshot) { + initWorkflowOverview(); + const workflow = snapshot?.workflow; + if (!workflow) { + renderWorkflowLoading('Backend не вернул поле workflow'); + return; + } + if (workflow.is_loading) { + renderWorkflowLoading('Workflow загружается'); + return; + } + if (workflow.unavailable_reason) { + renderWorkflowLoading(workflow.unavailable_reason); + return; + } + if (!workflowUi.dirty && !workflowUi.drag && !workflowUi.connectingFrom) { + workflowUi.draftEdges = (workflow.definition?.edges || []).map((edge) => ({ ...edge })); + workflowUi.draftPositions = Object.fromEntries((workflow.agents || []).map((agent) => [ + agent.id, { ...(agent.position || { x: 80, y: 80 }) }, + ])); + } + renderWorkflowKpis(snapshot); + renderWorkflowHeader(workflow); + renderWorkflowNodes(workflow); + renderWorkflowInspector(snapshot, workflow); + renderWorkflowEvents(workflow.events || []); + requestAnimationFrame(drawWorkflowEdges); +} + +function renderWorkflowLoading(reason) { + ['workflow-kpi-active', 'workflow-kpi-online', 'workflow-kpi-latency', 'workflow-kpi-tokens', 'workflow-kpi-success'] + .forEach((id) => wfUnavailable(id, reason)); + const empty = document.getElementById('workflow-empty'); + if (empty) { + empty.classList.remove('hidden'); + empty.innerHTML = `Workflow недоступен${wfEscape(reason)}`; + } +} + +function renderWorkflowKpis(snapshot) { + const metrics = snapshot.metrics || {}; + const telemetry = metrics.telemetry || {}; + const global = telemetry.global || {}; + const workflow = snapshot.workflow || {}; + const run = workflow.run || {}; + const active = run.status === 'running' ? 1 : 0; + setWorkflowKpi('workflow-kpi-active', String(active), 'Источник: workflow.run.status'); + + const readiness = snapshot.readiness || {}; + if (readiness.roles_ready_count === null || readiness.roles_ready_count === undefined || readiness.total_roles === undefined) { + wfUnavailable('workflow-kpi-online', 'readiness не содержит число готовых ролей'); + } else { + setWorkflowKpi('workflow-kpi-online', `${readiness.roles_ready_count} / ${readiness.total_roles}`, 'Источник: readiness'); + } + if (!global.total_calls) { + wfUnavailable('workflow-kpi-latency', 'за 24 часа нет измеренных вызовов'); + wfUnavailable('workflow-kpi-success', 'за 24 часа нет завершённых вызовов'); + } else { + const latency = wfValue(global.latency_p50_ms, ' мс'); + latency ? setWorkflowKpi('workflow-kpi-latency', latency, 'Медиана p50, telemetry') : wfUnavailable('workflow-kpi-latency', 'провайдер не вернул задержку'); + const success = global.successful_calls / global.total_calls * 100; + setWorkflowKpi('workflow-kpi-success', `${success.toFixed(1)}%`, `${global.successful_calls} из ${global.total_calls}, telemetry`); + } + if (global.total_tokens === null || global.total_tokens === undefined) { + wfUnavailable('workflow-kpi-tokens', 'провайдеры не вернули usage'); + } else { + setWorkflowKpi('workflow-kpi-tokens', new Intl.NumberFormat('ru-RU').format(global.total_tokens), 'Источник: telemetry usage, 24 ч'); + } +} + +function setWorkflowKpi(id, value, reason) { + const node = document.getElementById(id); + const detail = document.getElementById(`${id}-reason`); + if (node) node.textContent = value; + if (detail) detail.textContent = reason; +} + +function renderWorkflowHeader(workflow) { + const definition = workflow.definition || {}; + const run = workflow.run || {}; + document.getElementById('workflow-title').textContent = definition.name || 'Workflow без названия'; + const state = document.getElementById('workflow-run-state'); + state.textContent = workflowRunLabel(run.status); + state.className = `workflow-run-state ${wfEscape(run.status || 'idle')}`; + document.getElementById('workflow-iteration').textContent = run.iteration || 'Н/Д'; + if (!workflowUi.dirty) document.getElementById('workflow-max-iterations').value = definition.max_iterations || 1; + document.getElementById('workflow-run-error').textContent = run.error || ''; + document.getElementById('btn-workflow-start').disabled = run.status === 'running' || run.status === 'stopping'; + document.getElementById('btn-workflow-stop').disabled = run.status !== 'running'; +} + +function workflowRunLabel(status) { + return ({ idle: 'Нет активного запуска', running: '● Запущен', stopping: 'Останавливается', completed: 'Завершён', failed: 'Ошибка', stopped: 'Остановлен', interrupted: 'Прерван перезапуском' })[status] || `Н/Д: неизвестный статус ${status || 'не указан'}`; +} + +function renderWorkflowNodes(workflow) { + const layer = document.getElementById('workflow-node-layer'); + const canvas = document.getElementById('workflow-canvas'); + const agents = workflow.agents || []; + canvas.classList.toggle('workflow-mode-edit', workflowUi.mode === 'edit'); + document.getElementById('workflow-empty').classList.toggle('hidden', agents.length > 0); + layer.innerHTML = agents.map((agent) => { + const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 }; + const cfg = agent.execution_config || {}; + const assignment = cfg.unavailable_reason + ? `Н/Д: ${cfg.unavailable_reason}` + : [cfg.provider, cfg.model, cfg.account].filter(Boolean).join(' · '); + return `
+ +

${wfEscape(agent.name)}

${wfEscape(agent.role)}

${wfEscape(agent.agent_file)}

${wfEscape(assignment)}

+
${wfEscape(runtimeStateLabel(agent.runtime_state))}
+
`; + }).join(''); + layer.style.transform = `scale(${workflowUi.scale})`; + layer.querySelectorAll('.workflow-node').forEach((node) => { + node.addEventListener('click', () => selectWorkflowAgent(node.dataset.agentId)); + node.addEventListener('mousedown', beginNodeDrag); + node.querySelector('.workflow-port.out')?.addEventListener('mousedown', beginConnection); + node.querySelector('.workflow-port.in')?.addEventListener('mouseup', finishConnection); + }); + renderWorkflowMinimap(agents); +} + +function runtimeStateLabel(state) { + return ({ waiting: 'Ожидает', working: 'Работает', reviewing: 'Проверяет', error: 'Ошибка', completed: 'Завершено', not_implemented: 'Исполнение не реализовано' })[state] || `Н/Д: ${state || 'статус не получен'}`; +} + +function setWorkflowMode(mode) { + if (mode === 'edit' && currentSnapshot?.workflow?.run?.status === 'running') { + showToast('EDIT недоступен во время LIVE-выполнения', 'warning'); + return; + } + workflowUi.mode = mode; + document.querySelectorAll('.workflow-mode-btn').forEach((button) => button.classList.toggle('active', button.dataset.mode === mode)); + renderWorkflowOverview(currentSnapshot); +} + +function selectWorkflowAgent(agentId) { + if (workflowUi.drag?.moved) return; + workflowUi.selectedAgentId = agentId; + renderWorkflowOverview(currentSnapshot); +} + +function beginNodeDrag(event) { + if (workflowUi.mode !== 'edit' || event.target.classList.contains('workflow-port')) return; + const node = event.currentTarget; + const position = workflowUi.draftPositions[node.dataset.agentId] || { x: node.offsetLeft, y: node.offsetTop }; + workflowUi.drag = { id: node.dataset.agentId, startX: event.clientX, startY: event.clientY, original: { ...position }, moved: false }; + event.preventDefault(); +} + +function workflowPointerMove(event) { + if (workflowUi.drag) { + const drag = workflowUi.drag; + const dx = (event.clientX - drag.startX) / workflowUi.scale; + const dy = (event.clientY - drag.startY) / workflowUi.scale; + if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true; + workflowUi.draftPositions[drag.id] = { x: Math.max(0, drag.original.x + dx), y: Math.max(0, drag.original.y + dy) }; + const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(drag.id)}"]`); + if (node) { + node.style.left = `${workflowUi.draftPositions[drag.id].x}px`; + node.style.top = `${workflowUi.draftPositions[drag.id].y}px`; + } + drawWorkflowEdges(); + } +} + +function workflowPointerUp(event) { + if (workflowUi.drag) { + if (workflowUi.drag.moved) markWorkflowDirty(); + workflowUi.drag = null; + } + if (workflowUi.connectingFrom && event.target === document.getElementById('workflow-canvas')) { + workflowUi.connectingFrom = null; + showToast('Создание связи отменено', 'info'); + } +} + +function workflowPointerCancel() { + if (workflowUi.drag) { + workflowUi.draftPositions[workflowUi.drag.id] = workflowUi.drag.original; + workflowUi.drag = null; + renderWorkflowOverview(currentSnapshot); + } + workflowUi.connectingFrom = null; +} + +function beginConnection(event) { + if (workflowUi.mode !== 'edit') return; + workflowUi.connectingFrom = event.currentTarget.closest('.workflow-node').dataset.agentId; + event.stopPropagation(); + event.preventDefault(); + showToast('Выберите входной порт целевого агента', 'info'); +} + +function finishConnection(event) { + const target = event.currentTarget.closest('.workflow-node').dataset.agentId; + const source = workflowUi.connectingFrom; + workflowUi.connectingFrom = null; + event.stopPropagation(); + if (!source || source === target) return; + openEdgeDialog({ id: `edge-${Date.now()}`, source, target, condition: 'SUCCESS', label: '' }, true); +} + +function drawWorkflowEdges() { + const canvas = document.getElementById('workflow-canvas'); + const svg = document.getElementById('workflow-edges'); + const layer = document.getElementById('workflow-edge-layer'); + if (!canvas || !svg || !layer) return; + svg.setAttribute('viewBox', `0 0 ${canvas.clientWidth} ${canvas.clientHeight}`); + const parts = []; + workflowUi.draftEdges.forEach((edge) => { + const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`); + const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`); + if (!source || !target) return; + const x1 = (source.offsetLeft + source.offsetWidth) * workflowUi.scale; + const y1 = (source.offsetTop + source.offsetHeight / 2) * workflowUi.scale; + const x2 = target.offsetLeft * workflowUi.scale; + const y2 = (target.offsetTop + target.offsetHeight / 2) * workflowUi.scale; + const bend = Math.max(45, Math.abs(x2 - x1) * .42); + const path = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`; + const klass = String(edge.condition || '').toLowerCase(); + const labelX = (x1 + x2) / 2; + const labelY = (y1 + y2) / 2 - 5; + parts.push(`${wfEscape(edge.label || edge.condition)}`); + }); + layer.innerHTML = parts.join(''); + layer.querySelectorAll('.workflow-edge-hit').forEach((path) => path.addEventListener('click', () => { + const edge = workflowUi.draftEdges.find((item) => item.id === path.dataset.edgeId); + if (workflowUi.mode === 'edit' && edge) openEdgeDialog(edge, false); + })); +} + +function renderWorkflowMinimap(agents) { + const minimap = document.getElementById('workflow-minimap'); + if (!minimap) return; + const maxX = Math.max(900, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 200)); + const maxY = Math.max(500, ...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 100)); + minimap.innerHTML = agents.map((agent) => { + const pos = workflowUi.draftPositions[agent.id] || { x: 0, y: 0 }; + return ``; + }).join(''); +} + +function setWorkflowScale(value) { + workflowUi.scale = Math.max(.5, Math.min(1.6, Math.round(value * 10) / 10)); + document.getElementById('workflow-zoom-value').textContent = `${Math.round(workflowUi.scale * 100)}%`; + renderWorkflowOverview(currentSnapshot); +} + +function fitWorkflowGraph() { + const agents = currentSnapshot?.workflow?.agents || []; + const maxX = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 210), 600); + const maxY = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 110), 400); + const canvas = document.getElementById('workflow-canvas'); + setWorkflowScale(Math.min(canvas.clientWidth / maxX, canvas.clientHeight / maxY, 1)); +} + +function markWorkflowDirty() { + workflowUi.dirty = true; + const state = document.getElementById('workflow-save-state'); + state.textContent = 'Есть несохранённые изменения'; + state.classList.add('dirty'); +} + +async function saveWorkflowDraft() { + const definition = currentSnapshot?.workflow?.definition || {}; + const result = await executeAction('save_workflow', { + id: definition.id, + name: definition.name, + start_agent_id: definition.start_agent_id, + escalation_agent_id: definition.escalation_agent_id, + max_iterations: Number(document.getElementById('workflow-max-iterations').value), + edges: workflowUi.draftEdges, + agents: Object.entries(workflowUi.draftPositions).map(([id, position]) => ({ id, position })), + }); + if (result.ok) { + workflowUi.dirty = false; + const state = document.getElementById('workflow-save-state'); + state.textContent = 'Сохранено'; + state.classList.remove('dirty'); + } +} + +function renderWorkflowInspector(snapshot, workflow) { + const box = document.getElementById('workflow-inspector'); + const agent = (workflow.agents || []).find((item) => item.id === workflowUi.selectedAgentId); + if (!agent) { + box.innerHTML = '
INSPECTORВыберите агента на графе
'; + return; + } + const cfg = agent.execution_config || {}; + const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']]; + box.innerHTML = `

INSPECTOR: ${wfEscape(agent.name)}

${tabs.map(([id, label]) => ``).join('')}
`; + box.querySelectorAll('.inspector-tabs button').forEach((button) => button.addEventListener('click', () => { + workflowUi.selectedTab = button.dataset.tab; + renderWorkflowInspector(snapshot, workflow); + })); + const content = document.getElementById('inspector-tab-content'); + if (workflowUi.selectedTab === 'main') { + content.innerHTML = `
`; + document.getElementById('agent-save-main').onclick = () => executeAction('update_agent', { agent_id: agent.id, name: document.getElementById('agent-edit-name').value, description: document.getElementById('agent-edit-description').value }); + document.getElementById('agent-delete').onclick = () => deleteWorkflowAgent(agent); + } else if (workflowUi.selectedTab === 'model') { + renderAgentModelTab(content, snapshot, agent); + } else if (workflowUi.selectedTab === 'instructions') { + content.innerHTML = `
Agent File${wfEscape(agent.agent_file)}${agent.agent_file_exists ? 'Файл существует' : 'Н/Д: файл отсутствует'}
`; + document.getElementById('agent-file-open').onclick = () => openAgentFileEditor(agent); + } else if (workflowUi.selectedTab === 'tools') { + content.innerHTML = ``; + document.getElementById('agent-tools-save').onclick = () => executeAction('update_agent', { agent_id: agent.id, tools: document.getElementById('agent-tools').value.split(',').map((v) => v.trim()).filter(Boolean) }); + } else if (workflowUi.selectedTab === 'memory') { + content.innerHTML = `
${Object.keys(agent.memory_configuration || {}).length ? `
${wfEscape(JSON.stringify(agent.memory_configuration, null, 2))}
` : 'Н/Д: конфигурация памяти не задана'}
`; + } else { + const history = (workflow.events || []).filter((event) => event.agent_id === agent.id); + content.innerHTML = history.length ? history.slice(-20).reverse().map((event) => `
${wfEscape(event.message)}${event.duration_seconds == null ? '' : `${event.duration_seconds} с`}
`).join('') : '
Н/Д: у агента ещё нет запусков
'; + } +} + +function renderAgentModelTab(content, snapshot, agent) { + const profiles = Object.values(snapshot.all_profiles || {}).filter((profile) => isConnectedProfile(profile)); + const cfg = agent.execution_config || {}; + const providers = [...new Set(profiles.map((profile) => profile.provider))]; + content.innerHTML = ``; + const providerSelect = document.getElementById('agent-provider'); + const accountSelect = document.getElementById('agent-account'); + const modelSelect = document.getElementById('agent-model'); + const refreshAccounts = () => { + const matches = profiles.filter((profile) => profile.provider === providerSelect.value); + accountSelect.innerHTML = matches.length ? matches.map((profile) => ``).join('') : ''; + refreshModels(); + }; + const refreshModels = () => { + const profile = profiles.find((item) => item.profile_id === accountSelect.value); + const models = profile?.preferred_models || Object.values(profile?.model_states || {}).map((state) => state.display_name).filter(Boolean); + modelSelect.innerHTML = models.length ? models.map((model) => ``).join('') : ''; + }; + providerSelect.onchange = refreshAccounts; + accountSelect.onchange = refreshModels; + refreshAccounts(); + document.getElementById('agent-model-save').onclick = () => executeAction('update_agent', { + agent_id: agent.id, provider: providerSelect.value, profile_id: accountSelect.value, model: modelSelect.value, + temperature: nullableNumber('agent-temperature'), max_tokens: nullableNumber('agent-max-tokens'), timeout: nullableNumber('agent-timeout'), + }); +} + +function nullableNumber(id) { + const value = document.getElementById(id).value.trim(); + return value === '' ? null : Number(value); +} + +async function deleteWorkflowAgent(agent) { + let result = await executeAction('delete_agent', { agent_id: agent.id }); + if (result.ok && result.data?.confirmation_required) { + const refs = result.data.consequences?.workflow_edges || []; + if (!confirm(`Агент участвует в маршруте и/или графе. Будут удалены связи: ${refs.length ? refs.join(', ') : 'нет'}. Продолжить?`)) return; + result = await executeAction('delete_agent', { agent_id: agent.id, force: true }); + } + if (result.ok && result.data?.deleted) workflowUi.selectedAgentId = null; +} + +function openAgentCreateDialog() { + const profiles = Object.values(currentSnapshot?.all_profiles || {}).filter((profile) => isConnectedProfile(profile)); + openWorkflowDialog('Добавить агента', ``, async (close) => { + const account = document.getElementById('new-agent-account').value; + const profile = profiles.find((item) => item.profile_id === account); + const result = await executeAction('create_agent', { + name: document.getElementById('new-agent-name').value, + role: document.getElementById('new-agent-role').value, + description: document.getElementById('new-agent-description').value, + account, + model: profile?.preferred_models?.[0] || null, + agent_file: document.getElementById('new-agent-file').value, + }); + if (result.ok) close(); + }); +} + +async function openAgentFileEditor(agent) { + const result = await executeAction('read_agent_file', { agent_id: agent.id }); + if (!result.ok) return; + const data = result.data || {}; + if (!data.exists) { + showToast(`Н/Д: ${data.reason}`, 'warning'); + return; + } + openWorkflowDialog(`Agent File — ${agent.name}`, `

Нет изменений

`, async (close) => { + const saved = await executeAction('save_agent_file', { agent_id: agent.id, content: document.getElementById('agent-file-content').value }); + if (saved.ok) close(); + }, 'Сохранить'); + const editor = document.getElementById('agent-file-content'); + editor.addEventListener('input', () => { document.getElementById('agent-file-unsaved').textContent = 'Есть несохранённые изменения'; }); +} + +function openEdgeDialog(edge, isNew) { + openWorkflowDialog(isNew ? 'Новое ребро' : 'Редактор ребра', `${isNew ? '' : ''}`, (close) => { + const updated = { ...edge, condition: document.getElementById('edge-condition').value, label: document.getElementById('edge-label').value }; + const index = workflowUi.draftEdges.findIndex((item) => item.id === edge.id); + if (index >= 0) workflowUi.draftEdges[index] = updated; else workflowUi.draftEdges.push(updated); + markWorkflowDirty(); close(); drawWorkflowEdges(); + }); + if (!isNew) document.getElementById('edge-delete').onclick = () => { + workflowUi.draftEdges = workflowUi.draftEdges.filter((item) => item.id !== edge.id); + markWorkflowDirty(); document.querySelector('.workflow-dialog-backdrop')?.remove(); drawWorkflowEdges(); + }; +} + +function openWorkflowDialog(title, body, onSave, saveLabel = 'Применить') { + document.querySelector('.workflow-dialog-backdrop')?.remove(); + const backdrop = document.createElement('div'); + backdrop.className = 'workflow-dialog-backdrop'; + backdrop.innerHTML = `

${wfEscape(title)}

${body}
`; + document.body.appendChild(backdrop); + const close = () => backdrop.remove(); + backdrop.querySelector('[data-dialog-cancel]').onclick = close; + backdrop.querySelector('[data-dialog-save]').onclick = () => onSave(close); + backdrop.addEventListener('click', (event) => { if (event.target === backdrop) close(); }); +} + +async function startWorkflowRun() { + if (workflowUi.dirty) { + showToast('Сначала сохраните изменения графа', 'warning'); + return; + } + const task = document.getElementById('workflow-task').value.trim(); + await executeAction('start_workflow', { task }); + setWorkflowMode('live'); +} + +function renderWorkflowEvents(events) { + const box = document.getElementById('workflow-events-list'); + if (!events.length) { + box.innerHTML = '

Н/Д: workflow ещё не создавал событий

'; + return; + } + box.innerHTML = events.slice(-20).reverse().map((event) => `
${wfEscape(event.message)}${event.error ? ` — ${wfEscape(event.error)}` : ''}${wfEscape(event.type)}
`).join(''); +} + +function formatWorkflowTime(value) { + if (!value) return 'Н/Д'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU'); +} diff --git a/src/antigravity_provider/router/workflow_service.py b/src/antigravity_provider/router/workflow_service.py new file mode 100644 index 0000000..b0db3b8 --- /dev/null +++ b/src/antigravity_provider/router/workflow_service.py @@ -0,0 +1,646 @@ +"""Persistent agents, workflow graph and live execution for Hermes Hub. + +The router role registry remains the source of truth for logical agents and +Provider -> Account -> Model assignment. This module adds the pieces that do +not fit the routing schema: Agent Files, editor layout, workflow transitions, +execution checkpoints and a bounded event journal. +""" +from __future__ import annotations + +import json +import re +import threading +import time +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Optional + +from antigravity_provider import paths +from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config + + +AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"} +EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"} + + +def _utc_timestamp() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def _slug(value: str) -> str: + result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + return result or f"agent-{uuid.uuid4().hex[:8]}" + + +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() + candidate_name = Path(value or f"{agent_id}.md").name + if not candidate_name.lower().endswith(".md"): + candidate_name += ".md" + target = (root / candidate_name).resolve() + if target.parent != root: + raise ValueError("Agent File должен находиться в каталоге agents") + return target, f"agents/{candidate_name}" + + +@dataclass +class AgentDefinition: + id: str + name: str + role: str + description: str = "" + agent_file: str = "" + tools: list[str] = field(default_factory=list) + memory_configuration: dict[str, Any] = field(default_factory=dict) + execution_policy: dict[str, Any] = field(default_factory=dict) + timeout: int = 180 + temperature: Optional[float] = None + max_tokens: Optional[int] = None + position: dict[str, float] = field(default_factory=lambda: {"x": 80.0, "y": 80.0}) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class WorkflowEdge: + id: str + source: str + target: str + condition: str = "SUCCESS" + label: str = "" + + +@dataclass +class WorkflowDefinition: + id: str = "main" + name: str = "Основной workflow" + edges: list[WorkflowEdge] = field(default_factory=list) + max_iterations: int = 5 + escalation_agent_id: Optional[str] = None + start_agent_id: Optional[str] = None + + +@dataclass +class WorkflowEvent: + timestamp: str + type: str + message: str + level: str = "info" + run_id: Optional[str] = None + agent_id: Optional[str] = None + iteration: Optional[int] = None + provider: Optional[str] = None + account: Optional[str] = None + model: Optional[str] = None + duration_seconds: Optional[float] = None + error: Optional[str] = None + + +class WorkflowService: + _instance: Optional["WorkflowService"] = None + _instance_lock = threading.Lock() + + def __init__(self, state_path: Optional[Path] = None) -> None: + self.state_path = state_path or paths.get_workflow_state_path() + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self.agents: dict[str, AgentDefinition] = {} + self.workflow = WorkflowDefinition() + self.events: list[WorkflowEvent] = [] + self.run: dict[str, Any] = self._idle_run() + self._load() + self._migrate_router_roles() + + @classmethod + def get(cls) -> "WorkflowService": + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @staticmethod + def _idle_run() -> dict[str, Any]: + return { + "id": None, + "status": "idle", + "current_agent_id": None, + "current_task": None, + "iteration": 0, + "started_at": None, + "finished_at": None, + "elapsed_seconds": None, + "last_result": None, + "error": None, + "agent_states": {}, + } + + def _load(self) -> None: + if not self.state_path.is_file(): + return + try: + raw = json.loads(self.state_path.read_text(encoding="utf-8")) + self.agents = { + item["id"]: AgentDefinition(**item) + for item in raw.get("agents", []) + if isinstance(item, dict) and item.get("id") + } + wf = raw.get("workflow") or {} + edges = [WorkflowEdge(**edge) for edge in wf.pop("edges", []) if isinstance(edge, dict)] + self.workflow = WorkflowDefinition(edges=edges, **wf) + self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]] + self.run = raw.get("run") or self._idle_run() + if self.run.get("status") in {"running", "stopping"}: + self.run["status"] = "interrupted" + self.run["error"] = "Выполнение прервано перезапуском Hermes Hub; checkpoint сохранён" + self._event("WORKFLOW_INTERRUPTED", self.run["error"], level="warning") + except (OSError, ValueError, TypeError): + self.agents = {} + self.workflow = WorkflowDefinition() + self.events = [] + self.run = self._idle_run() + + def _save(self) -> None: + self.state_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": 1, + "agents": [asdict(agent) for agent in self.agents.values()], + "workflow": asdict(self.workflow), + "events": [asdict(event) for event in self.events[-200:]], + "run": self.run, + } + temp = self.state_path.with_suffix(".tmp") + temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temp.replace(self.state_path) + + def _migrate_router_roles(self) -> None: + config = load_router_config() + changed = False + for index, (role_id, policy) in enumerate(config.roles.items()): + if role_id in self.agents: + continue + target, relative = _safe_agent_file("", role_id) + name = role_id.replace("-", " ").title() + description = "" + try: + from antigravity_provider.router.role_registry import get_role_definition + + definition = get_role_definition(role_id) + name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name + description = getattr(definition, "description", "") + except (ImportError, AttributeError, TypeError): + pass + agent = AgentDefinition( + id=role_id, + name=name, + role=role_id, + description=description, + agent_file=relative, + position={"x": 70.0 + (index % 2) * 280.0, "y": 45.0 + (index // 2) * 125.0}, + ) + self.agents[role_id] = agent + self._ensure_file(target, agent) + changed = True + if changed: + self._save() + + @staticmethod + def _ensure_file(target: Path, agent: AgentDefinition) -> None: + if not target.exists(): + body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n" + target.write_text(body, encoding="utf-8") + + def _execution_config(self, agent: AgentDefinition) -> dict[str, Any]: + config = load_router_config() + policy = config.roles.get(agent.role) + profile_id = policy.preferred_chain[0] if policy and policy.preferred_chain else None + profile = config.profiles.get(profile_id) if profile_id else None + model = policy.default_model if policy else None + if not model and profile and profile.preferred_models: + model = profile.preferred_models[0] + return { + "provider": profile.provider if profile else None, + "account": profile_id, + "model": model, + "timeout": agent.timeout, + "temperature": agent.temperature, + "max_tokens": agent.max_tokens, + "unavailable_reason": None if profile else "Для роли не назначен доступный аккаунт", + } + + def snapshot(self) -> dict[str, Any]: + with self._lock: + agents = [] + for agent in self.agents.values(): + item = asdict(agent) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + item["agent_file"] = relative + item["agent_file_exists"] = target.is_file() + item["execution_config"] = self._execution_config(agent) + item["runtime_state"] = (self.run.get("agent_states") or {}).get(agent.id, "waiting") + agents.append(item) + return { + "agents": agents, + "definition": asdict(self.workflow), + "run": dict(self.run), + "events": [asdict(event) for event in self.events[-60:]], + "is_loading": False, + } + + def read_agent_file(self, agent_id: str) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + if not target.is_file(): + return {"path": relative, "exists": False, "content": None, "reason": "Файл не найден на диске"} + return {"path": relative, "exists": True, "content": target.read_text(encoding="utf-8")} + + def save_agent_file(self, agent_id: str, content: str) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + target, relative = _safe_agent_file(agent.agent_file, agent.id) + temporary = target.with_suffix(".md.tmp") + temporary.write_text(str(content), encoding="utf-8") + temporary.replace(target) + self._event("AGENT_FILE_SAVED", f"Сохранён Agent File {relative}", agent_id=agent_id) + self._save() + return {"path": relative, "exists": True} + + def create_agent(self, data: dict[str, Any]) -> AgentDefinition: + name = str(data.get("name") or "").strip() + role = _slug(str(data.get("role") or name)) + agent_id = _slug(str(data.get("id") or role)) + if not name: + raise ValueError("Укажите название агента") + with self._lock: + if agent_id in self.agents: + raise ValueError("Агент с таким идентификатором уже существует") + profile_id = str(data.get("account") or data.get("profile_id") or "").strip() + config = load_router_config() + if profile_id and profile_id not in config.profiles: + raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе") + config.roles[role] = RolePolicy( + role_name=role, + preferred_chain=[profile_id] if profile_id else [], + fallback_capabilities=list(data.get("fallback_capabilities") or [role]), + default_model=data.get("model") or None, + ) + if not save_router_config(config): + raise OSError("Не удалось сохранить назначение агента") + target, relative = _safe_agent_file(str(data.get("agent_file") or ""), agent_id) + agent = AgentDefinition( + id=agent_id, + name=name, + role=role, + description=str(data.get("description") or ""), + agent_file=relative, + tools=[str(item) for item in data.get("tools", [])], + memory_configuration=dict(data.get("memory_configuration") or {}), + execution_policy=dict(data.get("execution_policy") or {}), + timeout=max(1, int(data.get("timeout") or 180)), + temperature=float(data["temperature"]) if data.get("temperature") is not None else None, + max_tokens=int(data["max_tokens"]) if data.get("max_tokens") is not None else None, + position=dict(data.get("position") or {"x": 80.0, "y": 80.0}), + ) + self.agents[agent_id] = agent + copy_from = data.get("copy_from") + if copy_from: + source = self.read_agent_file(str(copy_from)) + target.write_text(source.get("content") or "", encoding="utf-8") + else: + self._ensure_file(target, agent) + self._event("AGENT_CREATED", f"Создан агент «{name}»", agent_id=agent_id) + self._save() + return agent + + def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition: + with self._lock: + agent = self._require_agent(agent_id) + config = load_router_config() + policy = config.roles.get(agent.role) + if not policy: + policy = RolePolicy(role_name=agent.role) + config.roles[agent.role] = policy + profile_id = str(data.get("account") or data.get("profile_id") or "").strip() + if profile_id: + profile = config.profiles.get(profile_id) + if not profile: + raise ValueError("Выбранный аккаунт отсутствует в маршрутизаторе") + requested_provider = str(data.get("provider") or "").strip() + if requested_provider and profile.provider != requested_provider: + raise ValueError("Аккаунт не принадлежит выбранному провайдеру") + policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id] + if "model" in data: + model = str(data.get("model") or "").strip() or None + if model and profile_id: + profile = config.profiles[profile_id] + if model not in profile.preferred_models: + raise ValueError("Модель не доступна выбранному аккаунту") + policy.default_model = model + if not save_router_config(config): + raise OSError("Не удалось сохранить назначение агента") + for attr in ("name", "description"): + if attr in data: + setattr(agent, attr, str(data[attr]).strip()) + for attr in ("tools", "memory_configuration", "execution_policy", "position"): + if attr in data: + setattr(agent, attr, type(getattr(agent, attr))(data[attr])) + for attr in ("timeout", "max_tokens"): + if attr in data and data[attr] is not None: + setattr(agent, attr, int(data[attr])) + if "temperature" in data: + agent.temperature = float(data["temperature"]) if data["temperature"] is not None else None + self._event("AGENT_UPDATED", f"Обновлён агент «{agent.name}»", agent_id=agent_id) + self._save() + return agent + + def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]: + with self._lock: + agent = self._require_agent(agent_id) + edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id] + route_used = bool(load_router_config().roles.get(agent.role)) + consequences = {"workflow_edges": edge_ids, "routing_role": agent.role if route_used else None} + if (edge_ids or route_used) and not force: + return {"deleted": False, "confirmation_required": True, "consequences": consequences} + config = load_router_config() + config.roles.pop(agent.role, None) + if not save_router_config(config): + raise OSError("Не удалось удалить роль из маршрутизатора") + self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids] + self.agents.pop(agent_id) + self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning") + self._save() + return {"deleted": True, "consequences": consequences} + + def save_workflow(self, data: dict[str, Any]) -> WorkflowDefinition: + with self._lock: + if self.run.get("status") in {"running", "stopping"}: + raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения") + edges: list[WorkflowEdge] = [] + seen: set[str] = set() + for raw in data.get("edges", []): + source, target = str(raw.get("source") or ""), str(raw.get("target") or "") + condition = str(raw.get("condition") or "SUCCESS").upper() + if source not in self.agents or target not in self.agents: + raise ValueError("Ребро ссылается на отсутствующего агента") + if condition not in EDGE_CONDITIONS: + raise ValueError(f"Неизвестное условие перехода: {condition}") + edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}") + if edge_id in seen: + raise ValueError("Идентификаторы рёбер должны быть уникальны") + seen.add(edge_id) + edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or ""))) + max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations) + if not 1 <= max_iterations <= 100: + raise ValueError("Предел итераций должен быть от 1 до 100") + for raw_agent in data.get("agents", []): + agent = self.agents.get(str(raw_agent.get("id") or "")) + pos = raw_agent.get("position") + if agent and isinstance(pos, dict): + agent.position = {"x": float(pos.get("x", 0)), "y": float(pos.get("y", 0))} + self.workflow = WorkflowDefinition( + id=str(data.get("id") or self.workflow.id), + name=str(data.get("name") or self.workflow.name), + edges=edges, + max_iterations=max_iterations, + escalation_agent_id=data.get("escalation_agent_id") or None, + start_agent_id=data.get("start_agent_id") or None, + ) + self._event("WORKFLOW_SAVED", f"Сохранён workflow «{self.workflow.name}»") + self._save() + return self.workflow + + def start(self, task: str) -> dict[str, Any]: + task = str(task or "").strip() + if not task: + raise ValueError("Для запуска укажите реальную задачу") + with self._lock: + if self._thread and self._thread.is_alive(): + raise ValueError("Workflow уже выполняется") + start_id = self.workflow.start_agent_id or (next(iter(self.agents), None)) + if not start_id or start_id not in self.agents: + raise ValueError("В workflow нет стартового агента") + self._stop.clear() + self.run = self._idle_run() + self.run.update({ + "id": uuid.uuid4().hex, + "status": "running", + "current_agent_id": start_id, + "current_task": task, + "iteration": 1, + "started_at": _utc_timestamp(), + }) + self._event("WORKFLOW_STARTED", "Workflow запущен", run_id=self.run["id"], iteration=1) + self._save() + self._thread = threading.Thread(target=self._execute, name="HermesWorkflow", daemon=True) + self._thread.start() + return dict(self.run) + + def stop(self) -> dict[str, Any]: + with self._lock: + if self.run.get("status") != "running": + raise ValueError("Нет выполняющегося workflow") + self.run["status"] = "stopping" + self._stop.set() + self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning") + self._save() + return dict(self.run) + + def _execute(self) -> None: + from antigravity_provider.router.router_engine import get_router_engine + + started = time.monotonic() + context = str(self.run.get("current_task") or "") + current = str(self.run.get("current_agent_id") or "") + visited: dict[str, int] = {} + try: + engine = get_router_engine() + engine.reload_config() + while current and not self._stop.is_set(): + with self._lock: + agent = self._require_agent(current) + visited[current] = visited.get(current, 0) + 1 + iteration = max(visited.values()) + self.run.update({"current_agent_id": current, "iteration": iteration}) + 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) + break + file_data = self.read_agent_file(current) + if not file_data["exists"]: + raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}") + self.run.setdefault("agent_states", {})[current] = ( + "reviewing" if "review" in agent.role.lower() else "working" + ) + self._event("AGENT_STARTED", f"{agent.name} начал выполнение", agent_id=current, iteration=iteration) + self._save() + request = { + "model": self._execution_config(agent).get("model"), + "messages": [ + {"role": "system", "content": file_data["content"]}, + {"role": "user", "content": context}, + ], + "timeout": agent.timeout, + "metadata": {"role": agent.role, "workflow_run_id": self.run["id"]}, + } + if agent.temperature is not None: + request["temperature"] = agent.temperature + if agent.max_tokens is not None: + request["max_tokens"] = agent.max_tokens + step_started = time.monotonic() + response = engine.route_request(request, role=agent.role, session_id=self.run["id"]) + duration = round(time.monotonic() - step_started, 3) + text = self._response_text(response) + status = self._result_status(response, text) + metadata = response.get("router_metadata", {}) if isinstance(response, dict) else {} + with self._lock: + self.run["last_result"] = {"status": status, "content": text, "router_metadata": metadata} + self.run.setdefault("agent_states", {})[current] = ( + "error" if status in {"ERROR", "REVIEW_FAILED"} else "completed" + ) + self._event( + "AGENT_COMPLETED", + f"{agent.name}: {status}", + level="success" if status not in {"ERROR", "REVIEW_FAILED"} else "warning", + agent_id=current, + iteration=iteration, + provider=metadata.get("provider"), + account=metadata.get("profile_id"), + model=metadata.get("selected_model") or (metadata.get("selection_trace") or {}).get("selected_model"), + duration_seconds=duration, + error=text if status == "ERROR" else None, + ) + edge = next( + (item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}), + None, + ) + if not edge and status not in {"ERROR", "REVIEW_FAILED"}: + edge = next( + (item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}), + None, + ) + if not edge: + self.run["status"] = "failed" if status in {"ERROR", "REVIEW_FAILED"} else "completed" + if status == "ERROR": + self.run["error"] = text or "Провайдер вернул ERROR без текста" + self._event( + "WORKFLOW_FAILED" if self.run["status"] == "failed" else "WORKFLOW_COMPLETED", + f"Workflow завершён со статусом {status}", + level="error" if self.run["status"] == "failed" else "success", + agent_id=current, + iteration=iteration, + error=self.run.get("error") if self.run["status"] == "failed" else None, + ) + if status == "ERROR": + try: + from antigravity_provider.router.unified_health import EventLogService + + EventLogService.get().log( + "workflow", + f"Ошибка агента «{agent.name}»", + details=self.run["error"], + level="error", + ) + except Exception: + pass + break + self._event( + "WORKFLOW_TRANSITION", + f"Переход {current} → {edge.target}: {edge.condition}", + agent_id=current, + iteration=iteration, + ) + context = json.dumps({ + "original_task": self.run["current_task"], + "previous_agent": current, + "structured_result": {"status": status, "content": text}, + }, ensure_ascii=False) + current = edge.target + self._save() + with self._lock: + if self._stop.is_set(): + self.run.update({"status": "stopped", "error": "Остановлено пользователем"}) + self._event("WORKFLOW_STOPPED", "Workflow остановлен пользователем", level="warning") + except Exception as exc: + with self._lock: + self.run.update({"status": "failed", "error": str(exc)}) + self._event("PROVIDER_ERROR", "Ошибка выполнения workflow", level="error", agent_id=current, error=str(exc)) + try: + from antigravity_provider.router.unified_health import EventLogService + + EventLogService.get().log("workflow", "Ошибка выполнения workflow", details=str(exc), level="error") + except Exception: + pass + finally: + with self._lock: + self.run["finished_at"] = _utc_timestamp() + self.run["elapsed_seconds"] = round(time.monotonic() - started, 3) + 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): + return str(response) + choices = response.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message", {}) + return str(message.get("content") or choices[0].get("text") or "") + return str(response.get("content") or response.get("text") or response.get("output") or "") + + @staticmethod + def _result_status(response: Any, text: str) -> str: + if isinstance(response, dict): + explicit = response.get("status") or response.get("structured_status") + if explicit and str(explicit).upper() in EDGE_CONDITIONS: + return str(explicit).upper() + for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"): + if re.search(rf"\b{status}\b", text.upper()): + return status + return "SUCCESS" + + def _event(self, event_type: str, message: str, **kwargs: Any) -> None: + self.events.append(WorkflowEvent(_utc_timestamp(), event_type, message, **kwargs)) + self.events = self.events[-200:] + + def _require_agent(self, agent_id: str) -> AgentDefinition: + agent = self.agents.get(agent_id) + if not agent: + raise ValueError("Агент не найден") + return agent + + +def execute_workflow_action(action: str, data: dict[str, Any]) -> dict[str, Any]: + """Execute a workflow action through the shared ActionExecutor layer.""" + service = WorkflowService.get() + if action == "create_agent": + result = asdict(service.create_agent(data)) + elif action == "update_agent": + result = asdict(service.update_agent(str(data.get("agent_id") or ""), data)) + elif action == "delete_agent": + result = service.delete_agent(str(data.get("agent_id") or ""), bool(data.get("force"))) + elif action == "read_agent_file": + result = service.read_agent_file(str(data.get("agent_id") or "")) + elif action == "save_agent_file": + result = service.save_agent_file(str(data.get("agent_id") or ""), str(data.get("content") or "")) + elif action == "save_workflow": + result = asdict(service.save_workflow(data)) + elif action == "start_workflow": + result = service.start(str(data.get("task") or "")) + elif action == "stop_workflow": + result = service.stop() + else: + raise ValueError("Неизвестное действие workflow") + if action in {"create_agent", "update_agent", "delete_agent"}: + try: + from antigravity_provider.router.state_store import HubStateStore + + HubStateStore.get().refresh(force_scan=False) + except Exception: + pass + return {"ok": True, "message": "Выполнено", "data": result} diff --git a/tests/test_workflow_service_a30.py b/tests/test_workflow_service_a30.py new file mode 100644 index 0000000..b1400cf --- /dev/null +++ b/tests/test_workflow_service_a30.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json + +import pytest + +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + save_router_config, +) +from antigravity_provider.router.workflow_service import WorkflowService + + +@pytest.fixture +def workflow_service(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = RouterConfig( + profiles={ + "account-a": RouterProfileConfig( + profile_id="account-a", + provider="openai-codex", + preferred_models=["model-real-from-config"], + ) + }, + roles={"developer": RolePolicy(role_name="developer", preferred_chain=["account-a"])}, + ) + save_router_config(config) + return WorkflowService(tmp_path / "workflow_state.json") + + +def test_router_roles_migrate_to_agents_and_create_real_files(workflow_service, tmp_path): + snapshot = workflow_service.snapshot() + assert "developer" in [agent["id"] for agent in snapshot["agents"]] + agent = next(item for item in snapshot["agents"] if item["id"] == "developer") + assert agent["agent_file"] == "agents/developer.md" + assert agent["agent_file_exists"] is True + assert (tmp_path / "agents" / "developer.md").is_file() + assert agent["execution_config"]["account"] == "account-a" + assert agent["execution_config"]["model"] == "model-real-from-config" + + +def test_create_update_file_and_restart_persistence(workflow_service, tmp_path): + created = workflow_service.create_agent({ + "name": "Security Reviewer", + "role": "security-reviewer", + "account": "account-a", + "model": "model-real-from-config", + "description": "Проверяет безопасность", + }) + workflow_service.save_agent_file(created.id, "# Security\n\nOnly measured facts.") + workflow_service.update_agent(created.id, {"timeout": 91, "position": {"x": 33, "y": 44}}) + + restarted = WorkflowService(tmp_path / "workflow_state.json") + item = next(agent for agent in restarted.snapshot()["agents"] if agent["id"] == created.id) + assert item["timeout"] == 91 + assert item["position"] == {"x": 33, "y": 44} + assert restarted.read_agent_file(created.id)["content"].endswith("Only measured facts.") + + +def test_delete_requires_explicit_confirmation_when_referenced(workflow_service): + workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"}) + workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 3, + "edges": [{"id": "review", "source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}], + }) + warning = workflow_service.delete_agent("test-reviewer") + assert warning["confirmation_required"] is True + assert warning["consequences"]["workflow_edges"] == ["review"] + result = workflow_service.delete_agent("test-reviewer", force=True) + assert result["deleted"] is True + assert workflow_service.workflow.edges == [] + + +def test_cycles_are_valid_and_iteration_limit_is_persisted(workflow_service): + workflow_service.create_agent({"name": "Test Reviewer", "role": "test-reviewer", "account": "account-a"}) + definition = workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 2, + "edges": [ + {"source": "developer", "target": "test-reviewer", "condition": "SUCCESS"}, + {"source": "test-reviewer", "target": "developer", "condition": "REVIEW_FAILED"}, + ], + }) + assert definition.max_iterations == 2 + assert definition.edges[1].condition == "REVIEW_FAILED" + payload = json.loads(workflow_service.state_path.read_text(encoding="utf-8")) + assert payload["workflow"]["max_iterations"] == 2 + + +def test_invalid_edge_and_unknown_model_are_rejected(workflow_service): + with pytest.raises(ValueError, match="отсутствующего агента"): + workflow_service.save_workflow({"edges": [{"source": "developer", "target": "missing"}]}) + with pytest.raises(ValueError, match="не доступна"): + workflow_service.update_agent("developer", {"account": "account-a", "model": "invented-model"}) + + +def test_interrupted_run_is_reported_not_silently_completed(workflow_service, tmp_path): + workflow_service.run.update({"id": "run-1", "status": "running", "current_agent_id": "developer"}) + workflow_service._save() + restarted = WorkflowService(tmp_path / "workflow_state.json") + assert restarted.run["status"] == "interrupted" + assert "перезапуском" in restarted.run["error"] + assert restarted.events[-1].type == "WORKFLOW_INTERRUPTED" + + +def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service, monkeypatch): + workflow_service.create_agent({"name": "Loop Reviewer", "role": "loop-reviewer", "account": "account-a"}) + workflow_service.save_workflow({ + "start_agent_id": "developer", + "max_iterations": 2, + "edges": [ + {"source": "developer", "target": "loop-reviewer", "condition": "SUCCESS"}, + {"source": "loop-reviewer", "target": "developer", "condition": "REVIEW_FAILED"}, + ], + }) + + class FakeEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + status = "REVIEW_FAILED" if role == "loop-reviewer" else "SUCCESS" + return { + "choices": [{"message": {"content": status}}], + "router_metadata": { + "provider": "measured-provider", + "profile_id": "account-a", + "selection_trace": {"selected_model": "model-real-from-config"}, + }, + } + + monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine()) + workflow_service.start("Проверить реальный цикл") + workflow_service._thread.join(timeout=3) + + assert workflow_service.run["status"] == "failed" + assert workflow_service.run["error"] == "Достигнут предел итераций: 2" + assert any(event.type == "WORKFLOW_MAX_ITERATIONS" for event in workflow_service.events) + + +def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatch): + provider_text = "Provider Error: authentication token missing for account-a" + + class ErrorEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + return {"choices": [{"message": {"content": f"ERROR\n{provider_text}"}}]} + + monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: ErrorEngine()) + workflow_service.workflow.start_agent_id = "developer" + workflow_service.workflow.edges = [] + workflow_service.start("Проверить ошибку") + workflow_service._thread.join(timeout=3) + + assert workflow_service.run["status"] == "failed" + assert provider_text in workflow_service.run["error"] + assert any(provider_text in (event.error or "") for event in workflow_service.events) + assert workflow_service.snapshot()["agents"][0]["runtime_state"] == "error"