| Server IP : 85.155.190.233 / Your IP : 216.73.216.103 Web Server : nginx/1.24.0 System : Linux antigravity-cli 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64 User : wp-moonbloom ( 1001) PHP Version : 8.3.6 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /opt/Gemini_Swarm_Core/scripts/ |
Upload File : |
import asyncio
import aiosqlite
import contextlib
import signal
import time
import html
import logging
import os
import re
import shutil
import sys
import uuid
import collections
import hmac
import hashlib
import json
from datetime import datetime
from urllib.parse import parse_qsl
from pathlib import Path
# FastAPI imports
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
_TOOL_CALL_RE = re.compile(r'^\s*\w+\([^)]*\)\s*$')
from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.types import Message, BotCommand
# Custom log handler to keep recent logs in memory for API dashboard
class InMemoryLogHandler(logging.Handler):
def __init__(self, maxlen=300):
super().__init__()
self.logs = collections.deque(maxlen=maxlen)
def emit(self, record):
try:
log_entry = {
"timestamp": datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"),
"level": record.levelname,
"message": self.format(record)
}
self.logs.append(log_entry)
except Exception:
self.handleError(record)
# Base logging setup
in_memory_log = InMemoryLogHandler()
in_memory_log.setFormatter(logging.Formatter("%(message)s"))
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Standard console logging
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
logger.addHandler(console_handler)
# Add memory handler
logger.addHandler(in_memory_log)
_MD_CODE_BLOCK_RE = re.compile(r"```(\w*)\n?(.*?)\n?```", re.DOTALL)
_MD_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
_MD_LINK_RE = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)")
_MD_BOLD_RE = re.compile(r"\*\*([^\*\n]+)\*\*")
_MD_ITALIC_RE = re.compile(r"(?<![\*\w])\*([^\*\n]+)\*(?!\w)")
_MD_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
def md_to_tg_html(text: str) -> str:
"""Convert Markdown to Telegram-flavored HTML safely.
Order matters: protect code/links FIRST (so their content is not touched by
bold/italic regex), escape remaining text, then convert bold/italic, finally
restore the protected placeholders.
"""
placeholders: list[str] = []
def stash(content: str) -> str:
placeholders.append(content)
return f"\x00PH{len(placeholders) - 1}\x00"
text = _MD_CODE_BLOCK_RE.sub(
lambda m: stash(f"<pre>{html.escape(m.group(2))}</pre>"), text
)
text = _MD_INLINE_CODE_RE.sub(
lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), text
)
text = _MD_LINK_RE.sub(
lambda m: stash(
f'<a href="{html.escape(m.group(2), quote=True)}">{html.escape(m.group(1))}</a>'
),
text,
)
text = html.escape(text, quote=False)
text = _MD_HEADER_RE.sub(r"<b>\1</b>", text)
text = _MD_BOLD_RE.sub(r"<b>\1</b>", text)
text = _MD_ITALIC_RE.sub(r"<i>\1</i>", text)
for i, content in enumerate(placeholders):
text = text.replace(f"\x00PH{i}\x00", content)
return text
def strip_markdown(text: str) -> str:
"""Plain-text fallback if HTML rendering fails Telegram validation."""
text = _MD_CODE_BLOCK_RE.sub(lambda m: m.group(2), text)
text = _MD_INLINE_CODE_RE.sub(lambda m: m.group(1), text)
text = _MD_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2)})", text)
text = _MD_BOLD_RE.sub(r"\1", text)
text = _MD_ITALIC_RE.sub(r"\1", text)
text = _MD_HEADER_RE.sub(r"\1", text)
return text
TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
ALLOWED_USERS = [x.strip() for x in os.environ.get("TELEGRAM_ALLOWED_USERS", "").split(",") if x.strip()]
SWARM_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = SWARM_ROOT / "sessions.db"
# --- Dynamic Subagent Configurations & Fallbacks ---
AGENT_SKILLS_MAP = {
"oracle": (
"# Skills: Oracle\n"
"- **Notion Knowledge Retrieval:** Deep search and indexing of Notion automation workspace.\n"
"- **Semantic RAG:** Question-only vector representation and matching.\n"
"- **Agentic QA:** Delivering synthesized context answers."
),
"prometheus": (
"# Skills: Prometheus\n"
"- **Strategic Mapping:** Translating vague goals into technical requirements.\n"
"- **Risk Assessment:** Identifying edge cases and architectural bottlenecks.\n"
"- **Mission Decomposition:** Creating a step-by-step roadmap for parallel execution.\n"
"- **Constraint Management:** Ensuring plans stay within the bounds of Gemini CLI mandates and project policies."
),
"librarian": (
"# Skills: Librarian\n"
"- **Context Extraction:** Finding needles in haystacks within large codebases.\n"
"- **Dependency Mapping:** Understanding how modules and services interact.\n"
"- **Pattern Recognition:** Identifying established coding styles and architectural conventions.\n"
"- **Technical Research:** Synthesizing internal code knowledge with external documentation."
),
"junior": (
"# Skills: Junior\n"
"- **Surgical Implementation:** Making precise, minimal changes to achieve a goal.\n"
"- **Test-Driven Development:** Using tests to guide and verify implementation.\n"
"- **Debug Proficiency:** Identifying and fixing issues through empirical reproduction.\n"
"- **Environment Management:** Running complex builds and verification pipelines."
),
"quality-validator": (
"# Skills: Quality_Validator\n"
"- **Critical Review:** Identifying subtle bugs and stylistic inconsistencies.\n"
"- **Exhaustive Verification:** Running comprehensive test suites and edge-case analysis.\n"
"- **Standards Enforcement:** Ensuring strict adherence to `GEMINI.md` and workspace policies.\n"
"- **Reporting:** Providing clear, actionable feedback for re-implementation if needed."
),
"frontend": (
"# Skills: Frontend\n"
"- **Vanilla CSS & Modern Layouts:** Expertise in CSS Grid, Flexbox, Custom Properties, and responsive layout patterns.\n"
"- **Semantic HTML & Accessibility:** Deep knowledge of WCAG standards, aria-roles, semantic layouts, and screen-reader accessibility.\n"
"- **Surgical UI Refinement:** Making highly precise layout and visual changes without impacting surrounding structures.\n"
"- **Interactive Prototyping:** Enhancing user interfaces with light, clean interactive logic using Vanilla JavaScript.\n"
"- **Testing & Verification Tools:** Utilizing developer tools (Chrome DevTools MCP), responsive testing frameworks, and linters."
),
"stitch": (
"# Skills: Stitch\n"
"- **Google Stitch Canvas Integration:** Utilizing `google-labs-code/stitch-skills` and Stitch MCP to extract canvas assets, inspect nodes, and query visual attributes.\n"
"- **Design Token Compilation:** Mapping design styles (typography, color ramps, shadow elevations, grid configurations) to standard CSS variables or design token files.\n"
"- **Automated Component Generation:** Compiling visual mockups into clean, semantic components ready for frontend integration.\n"
"- **Visual Regression Testing:** Verifying that compiled components align perfectly with visual source layouts on the canvas."
),
"jules": (
"# Skills: Jules\n"
"- **Autonomous Coding:** Solving complex programming tasks via Jules CLI.\n"
"- **Cloud Agent Integration:** Interacting with Google's cloud coding engine.\n"
"- **Refactoring:** Automated codebase improvements."
)
}
@contextlib.asynccontextmanager
async def connect_db():
db = await aiosqlite.connect(DB_PATH, timeout=30.0)
try:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout=10000")
await db.execute("PRAGMA synchronous=NORMAL")
yield db
finally:
await db.close()
_GIT_URL_RE = re.compile(r"^https://[a-zA-Z0-9._/\-]+$")
_GITHUB_REPO_RE = re.compile(r"https://github\.com/([a-zA-Z0-9_\-]+)/([a-zA-Z0-9_\-\.]+?)(?:\.git)?(?:\s|$)")
_ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
if not TOKEN:
logging.error("TELEGRAM_BOT_TOKEN is not set.")
sys.exit(1)
_ceo_instruction_path = SWARM_ROOT / "GEMINI.md"
_CEO_INSTRUCTION = (
_ceo_instruction_path.read_text(encoding="utf-8")
if _ceo_instruction_path.exists()
else "You are the Lead Orchestrator of the Antigravity Swarm. Answer in Russian. Be concise."
)
# Short persona prefix used in every call to keep prompt size manageable
_CEO_PERSONA = _CEO_INSTRUCTION[:600].strip()
# In-memory conversation history per chat session
_sessions: dict[str, list[dict]] = {}
_MAX_HISTORY = 20
_MAX_DB_HISTORY_LIMIT = 200
bot = Bot(token=TOKEN)
dp = Dispatcher()
_task_registry: dict[str, dict] = {}
_registry_lock = asyncio.Lock()
# --- FastAPI Setup & Endpoints ---
app = FastAPI(title="Gemini Swarm Mission Control", debug=False)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://proimport-shop.ru",
"https://www.proimport-shop.ru",
"https://web.telegram.org",
"https://k.telegram.org",
],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
def verify_telegram_signature(init_data: str, bot_token: str) -> bool:
try:
parsed = dict(parse_qsl(init_data))
if "hash" not in parsed:
return False
tg_hash = parsed.pop("hash")
sorted_params = sorted(parsed.items())
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted_params)
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(calculated_hash, tg_hash)
except Exception:
return False
async def get_current_user(request: Request):
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
init_data = auth_header.split(" ", 1)[1]
if not verify_telegram_signature(init_data, TOKEN):
raise HTTPException(status_code=401, detail="Invalid token signature")
parsed = dict(parse_qsl(init_data))
user_data = parsed.get("user")
if not user_data:
raise HTTPException(status_code=401, detail="No user data")
try:
user_info = json.loads(user_data)
user_id = str(user_info.get("id"))
except Exception:
raise HTTPException(status_code=401, detail="Invalid user data format")
if user_id not in ALLOWED_USERS:
raise HTTPException(status_code=403, detail="Forbidden user")
return user_id
class ConfirmRequest(BaseModel):
job_id: str
approved: bool
class SettingsRequest(BaseModel):
auto_approve_routine: bool
max_history_limit: int
class RollbackRequest(BaseModel):
commit_hash: str
_FILE_CACHE = {} # path_str -> (mtime, content, timestamp)
def read_cached_file(path: Path) -> str:
path_str = str(path)
now = time.time()
if path_str in _FILE_CACHE:
cache_time, content, check_time = _FILE_CACHE[path_str]
if now - check_time < 5.0:
return content
try:
if not path.exists():
return ""
mtime = path.stat().st_mtime
if path_str in _FILE_CACHE:
cache_time, content, check_time = _FILE_CACHE[path_str]
if cache_time == mtime:
_FILE_CACHE[path_str] = (mtime, content, now)
return content
content = path.read_text(encoding="utf-8")
_FILE_CACHE[path_str] = (mtime, content, now)
return content
except Exception as e:
logging.error(f"Error reading {path}: {e}")
if path_str in _FILE_CACHE:
return _FILE_CACHE[path_str][1]
raise
@app.get("/api/status")
async def api_status(user_id: str = Depends(get_current_user)):
async with _registry_lock:
running_tasks = list(_task_registry.values())
status_path = SWARM_ROOT / "SWARM_STATUS.md"
objective = "Не определена"
last_update = "Неизвестно"
agents_list = []
recent_logs = []
# Defaults in case file parsing fails
default_agents = [
{"name": "CEO", "status_raw": "🟢 IDLE", "status": "idle", "task": "Monitoring Swarm", "progress": "100%"},
{"name": "Prometheus", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
{"name": "Librarian", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
{"name": "Junior", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
{"name": "Frontend", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
{"name": "Stitch", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
{"name": "Quality-Validator", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"}
]
if status_path.exists():
try:
content = read_cached_file(status_path)
# Parse last update
import re
m_update = re.search(r"\*\*Last Update:\*\*?\s*(.*)", content)
if m_update:
last_update = m_update.group(1).strip()
# Parse global objective
m_obj = re.search(r"\*\*Current Global Objective:\*\*?\s*(.*)", content)
if m_obj:
objective = m_obj.group(1).strip()
# Parse Agent Status Matrix
lines = content.splitlines()
in_matrix = False
for line in lines:
if "## 📊 Agent Status Matrix" in line:
in_matrix = True
continue
if in_matrix:
if line.strip() == "---" or (line.strip() == "" and len(agents_list) > 0) or line.strip().startswith("##"):
in_matrix = False
continue
if line.startswith("|") and not ":" in line and not "Agent" in line:
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5:
agent_name = parts[1].replace("**", "").strip()
status_raw = parts[2].strip()
current_task = parts[3].strip()
progress = parts[4].strip()
status_lower = status_raw.lower()
status_mapped = "idle"
if "running" in status_lower or "thinking" in status_lower or "выполняет" in status_lower or "🔵" in status_lower:
status_mapped = "running"
elif "paused" in status_lower or "waiting" in status_lower or "ожидает" in status_lower or "🟡" in status_lower:
status_mapped = "paused"
elif "idle" in status_lower or "🟢" in status_lower:
status_mapped = "idle"
elif "offline" in status_lower or "🔴" in status_lower:
status_mapped = "offline"
agents_list.append({
"name": agent_name,
"status_raw": status_raw,
"status": status_mapped,
"task": current_task,
"progress": progress
})
# Parse Recent Mission Log
in_logs = False
for line in lines:
if "## 📝 Recent Mission Log" in line:
in_logs = True
continue
if in_logs:
if line.strip() == "---" or line.strip().startswith("##"):
in_logs = False
continue
cleaned = line.strip()
if cleaned.startswith("- ") or cleaned.startswith("* "):
recent_logs.append(cleaned[2:])
elif cleaned and len(recent_logs) > 0 and not cleaned.startswith("|"):
recent_logs[-1] += " " + cleaned
except Exception as e:
logging.error(f"Error parsing SWARM_STATUS.md: {e}")
if not agents_list:
agents_list = default_agents
tasks_list = []
for task in running_tasks:
tasks_list.append({
"job_id": task["job_id"],
"repo_url": task["repo_url"],
"task_desc": task.get("task_desc", "Выполнение задачи"),
"pid": task["pid"],
"status": task.get("status", "running")
})
# System metrics
import os
import shutil
try:
load1, load5, load15 = os.getloadavg()
cpu_load = f"{load1:.2f}, {load5:.2f}, {load15:.2f}"
except Exception:
cpu_load = "N/A"
try:
with open("/proc/meminfo", "r") as f:
meminfo = {}
for line in f:
parts = line.split(":")
if len(parts) == 2:
meminfo[parts[0].strip()] = parts[1].strip()
mem_total = int(meminfo["MemTotal"].split()[0])
mem_avail = int(meminfo.get("MemAvailable", meminfo.get("MemFree", "0")).split()[0])
mem_used = mem_total - mem_avail
ram_info = f"{mem_used // 1024} MB / {mem_total // 1024} MB ({((mem_used/mem_total)*100):.1f}%)"
except Exception:
ram_info = "N/A"
try:
total, used, free = shutil.disk_usage("/")
disk_info = f"{used / (1024**3):.1f} GB / {total / (1024**3):.1f} GB ({((used/total)*100):.1f}%)"
except Exception:
disk_info = "N/A"
# Git branch & commit
git_branch = "unknown"
git_commit = "unknown"
try:
rc_branch, out_branch, _ = await _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], str(SWARM_ROOT))
if rc_branch == 0:
git_branch = out_branch.strip()
rc_commit, out_commit, _ = await _run_git(["git", "rev-parse", "--short", "HEAD"], str(SWARM_ROOT))
if rc_commit == 0:
git_commit = out_commit.strip()
except Exception:
pass
return {
"status": "active" if running_tasks else "idle",
"tasks": tasks_list,
"agents": agents_list,
"objective": objective,
"last_update": last_update,
"recent_logs": recent_logs[:5],
"system_stats": {
"cpu": cpu_load,
"ram": ram_info,
"disk": disk_info
},
"git_info": {
"branch": git_branch,
"commit": git_commit
}
}
@app.get("/api/git/history")
async def api_git_history(user_id: str = Depends(get_current_user)):
rc_active, active_hash, _ = await _run_git(["git", "rev-parse", "--short", "HEAD"], str(SWARM_ROOT))
active_hash = active_hash.strip()
rc_log, log_out, log_err = await _run_git(["git", "log", "-n", "10", '--pretty=format:%h|%an|%ar|%s'], str(SWARM_ROOT))
if rc_log != 0:
raise HTTPException(status_code=500, detail=f"Git log failed: {log_err}")
commits = []
for line in log_out.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("|", 3)
if len(parts) == 4:
h, author, when, subject = parts
commits.append({
"hash": h,
"author": author,
"when": when,
"subject": subject,
"active": h == active_hash or active_hash.startswith(h)
})
return {"commits": commits}
@app.post("/api/git/rollback")
async def api_git_rollback(req: RollbackRequest, user_id: str = Depends(get_current_user)):
# 1. Reset git to the specified commit
rc, out_git, err_git = await _run_git(["git", "reset", "--hard", req.commit_hash], str(SWARM_ROOT))
if rc != 0:
raise HTTPException(status_code=500, detail=f"Git reset failed: {err_git}")
# 2. Support migration/rsync if we rolled back to nested structure
gemini_dir = SWARM_ROOT / "Gemini_Swarm_Core"
if gemini_dir.exists() and gemini_dir.is_dir():
import subprocess
subprocess.run(f"rsync -a {gemini_dir}/ {SWARM_ROOT}/ && rm -rf {gemini_dir}", shell=True)
# 3. Asynchronously trigger background restart of the systemd service
import subprocess
subprocess.Popen("sleep 1 && systemctl restart tg-bridge.service", shell=True)
return {"status": "ok", "message": "Rollback successful. Restarting bridge service..."}
@app.get("/api/logs")
async def api_logs(user_id: str = Depends(get_current_user)):
return {"logs": list(in_memory_log.logs)}
def get_agent_permissions(agent_name: str) -> dict:
isolation_path = SWARM_ROOT / "swarm-isolation.toml"
allowed_tools = []
access_level = "Read-Only"
if isolation_path.exists():
try:
content = read_cached_file(isolation_path)
import re
rules = content.split("[[rule]]")
normalized_name = agent_name.lower().replace("-", "_").strip()
for rule in rules:
if not rule.strip():
continue
subagent_match = re.search(r'subagent\s*=\s*"([^"]+)"', rule)
if subagent_match:
rule_subagent = subagent_match.group(1).lower().replace("-", "_").strip()
if rule_subagent == normalized_name:
tools_match = re.search(r'toolName\s*=\s*\[(.*?)\]', rule, re.DOTALL)
if tools_match:
tools_str = tools_match.group(1)
tools = [t.strip().strip('"').strip("'") for t in tools_str.split(",") if t.strip()]
allowed_tools.extend(tools)
if any(t in allowed_tools for t in ["write_to_file", "replace_file_content", "multi_replace_file_content"]):
access_level = "Full Access"
elif "run_command" in allowed_tools:
access_level = "Testing & Verification"
elif any(t in allowed_tools for t in ["view_file", "grep_search", "list_dir"]):
access_level = "Read-Only"
except Exception as e:
logging.error(f"Error parsing swarm-isolation.toml: {e}")
if not allowed_tools:
if agent_name.lower() == "ceo":
access_level = "Orchestrator"
allowed_tools = ["all_tools"]
else:
access_level = "Read-Only"
allowed_tools = ["view_file", "grep_search", "list_dir"]
return {
"allowed_tools": allowed_tools,
"access_level": access_level
}
def get_agent_mcp_servers(agent_name: str) -> list:
active_mcps = []
mcp_config_paths = [
SWARM_ROOT / ".agents" / "plugins" / "antigravity-swarm" / "mcp_config.json",
Path("/root/.gemini/antigravity/mcp_config.json"),
Path("/root/.gemini/config/mcp_config.json")
]
for path in mcp_config_paths:
if path.exists():
try:
import json
mcp_data = json.loads(path.read_text(encoding="utf-8"))
servers = list(mcp_data.get("mcpServers", {}).keys())
for s in servers:
if s not in active_mcps:
active_mcps.append(s)
except Exception as e:
logging.error(f"Error parsing mcp_config.json at {path}: {e}")
mcp_servers = []
dir_name = agent_name.lower().strip()
if dir_name == "stitch":
mcp_servers.append({
"name": "Stitch MCP",
"active": "stitch" in active_mcps,
"description": "Google Stitch design canvas integration"
})
elif dir_name in ["prometheus", "librarian", "ceo"]:
mcp_servers.append({
"name": "Tavily MCP",
"active": "tavily" in active_mcps,
"description": "Web search and deep research"
})
mcp_servers.append({
"name": "Exa MCP",
"active": "exa" in active_mcps,
"description": "Semantic search for business data"
})
mcp_servers.append({
"name": "Google Developer Knowledge MCP",
"active": "google-developer-knowledge" in active_mcps,
"description": "Google Cloud, Android, Firebase, and Maps documentation search"
})
return mcp_servers
@app.get("/api/agent/{name}")
async def api_agent_detail(name: str, user_id: str = Depends(get_current_user)):
dir_name = name.lower().strip()
if not re.match(r"^[a-zA-Z0-9_\-]+$", dir_name):
raise HTTPException(status_code=400, detail="Invalid agent name format")
role_content = ""
instructions_content = ""
skills_content = ""
model_name = "gemini-3.5-flash" # default
# Custom mapping for model
if dir_name in ["librarian", "frontend"]:
model_name = "gemini-3.1-flash"
elif dir_name in ["prometheus", "junior", "quality-validator", "oracle", "jules", "stitch", "ceo"]:
model_name = "gemini-3.5-flash"
# CEO does not have a folder in swarm/
if dir_name == "ceo":
role_content = "Главный агент (CEO / Orchestrator). Координирует работу всего роя, общается с владельцем, планирует миссии и контролирует безопасность."
instructions_content = "Управление роем субагентов по методологии google-adk. Контроль безопасности выполнения команд на VPS. Соблюдение регламента ветвления Git."
skills_content = "# Skills: CEO\n- **Orchestration:** Multi-agent task delegation and coordination.\n- **Security Enforcement:** Checking system health and VPS commands.\n- **Git & PR Management:** Enforcing clean repository operations."
else:
# Dynamic parsing from GEMINI.md
parsed = parse_agent_from_gemini_md(dir_name)
role_content = parsed["role"]
instructions_content = parsed["instructions"]
# Look up skills from AGENT_SKILLS_MAP, with fallback
if dir_name in AGENT_SKILLS_MAP:
skills_content = AGENT_SKILLS_MAP[dir_name]
else:
skills_content = f"# Skills: {name}\n- **Core Operations:** Executing tasks assigned by CEO.\n- **Context Awareness:** Working within the scope of the project."
# Parse current status/task from SWARM_STATUS.md
status_path = SWARM_ROOT / "SWARM_STATUS.md"
current_status = "idle"
status_raw = "🟢 IDLE"
current_task = "-"
progress = "100%"
if status_path.exists():
try:
content = read_cached_file(status_path)
# Parse matrix lines to find the agent
for line in content.splitlines():
if line.startswith("|") and not ":" in line and not "Agent" in line:
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 5:
agent_name = parts[1].replace("**", "").strip()
if agent_name.lower() == name.lower():
status_raw = parts[2].strip()
current_task = parts[3].strip()
progress = parts[4].strip()
status_lower = status_raw.lower()
if "running" in status_lower or "thinking" in status_lower or "выполняет" in status_lower or "🔵" in status_lower:
current_status = "running"
elif "paused" in status_lower or "waiting" in status_lower or "ожидает" in status_lower or "🟡" in status_lower:
current_status = "paused"
elif "idle" in status_lower or "🟢" in status_lower:
current_status = "idle"
elif "offline" in status_lower or "🔴" in status_lower:
current_status = "offline"
break
except Exception as e:
logging.error(f"Error parsing status for agent details: {e}")
# Log filtering for this agent
# 1. From SWARM_STATUS.md (recent mission log)
filtered_recent_logs = []
if status_path.exists():
try:
content = read_cached_file(status_path)
lines = content.splitlines()
in_logs = False
for line in lines:
if "## 📝 Recent Mission Log" in line:
in_logs = True
continue
if in_logs:
if line.strip() == "---" or line.strip().startswith("##"):
in_logs = False
continue
cleaned = line.strip()
if cleaned.startswith("- ") or cleaned.startswith("* "):
log_msg = cleaned[2:]
# Filter criteria: contains agent name (case-insensitive) or specific keywords
if name.lower() in log_msg.lower() or (name.lower() == "quality-validator" and "validator" in log_msg.lower()):
filtered_recent_logs.append(log_msg)
except Exception as e:
logging.error(f"Error parsing logs for agent details: {e}")
# 2. From in-memory system logs
filtered_system_logs = []
# Search for occurrences of agent name in in-memory logs
for log in list(in_memory_log.logs):
msg = log.get("message", "")
# Look for agent name in message
if name.lower() in msg.lower() or (name.lower() == "quality-validator" and "validator" in msg.lower()):
filtered_system_logs.append(log)
permissions = get_agent_permissions(name)
mcp_servers = get_agent_mcp_servers(name)
return {
"name": name,
"role": role_content,
"instructions": instructions_content,
"skills": skills_content,
"status": current_status,
"status_raw": status_raw,
"task": current_task,
"progress": progress,
"model": model_name,
"recent_logs": filtered_recent_logs,
"system_logs": filtered_system_logs,
"allowed_tools": permissions["allowed_tools"],
"access_level": permissions["access_level"],
"mcp_servers": mcp_servers
}
@app.post("/api/confirm")
async def api_confirm(req: ConfirmRequest, user_id: str = Depends(get_current_user)):
async with _registry_lock:
task = _task_registry.get(req.job_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
try:
await _kill_process_tree(task["pid"])
logging.info(f"Task {req.job_id} (PID {task['pid']}) stopped by user via Mini App.")
except Exception as e:
logging.error(f"Failed to kill process {task['pid']}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to stop process: {e}")
return {"status": "ok"}
@app.get("/api/settings")
async def api_get_settings(user_id: str = Depends(get_current_user)):
auto_approve = True
history_limit = 20
try:
async with connect_db() as db:
async with db.execute("SELECT value FROM app_settings WHERE key = 'auto_approve_routine'") as cursor:
row = await cursor.fetchone()
if row:
auto_approve = row[0] == "true"
async with db.execute("SELECT value FROM app_settings WHERE key = 'max_history_limit'") as cursor:
row = await cursor.fetchone()
if row:
history_limit = int(row[0])
except Exception as e:
logging.error(f"Failed to read settings: {e}")
return {"auto_approve_routine": auto_approve, "max_history_limit": history_limit}
@app.post("/api/settings")
async def api_save_settings(req: SettingsRequest, user_id: str = Depends(get_current_user)):
try:
async with connect_db() as db:
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('auto_approve_routine', ?)",
("true" if req.auto_approve_routine else "false",)
)
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('max_history_limit', ?)",
(str(req.max_history_limit),)
)
await db.commit()
global _MAX_HISTORY
_MAX_HISTORY = req.max_history_limit
logging.info(f"Settings updated via Mini App: auto_approve={req.auto_approve_routine}, history_limit={req.max_history_limit}")
except Exception as e:
logging.error(f"Failed to save settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"status": "saved"}
# Mount static files for Telegram Mini App
mini_app_dir = Path("/opt/Gemini_Swarm_Core/src/web")
if not mini_app_dir.exists():
mini_app_dir = SWARM_ROOT / "src" / "web"
if not mini_app_dir.exists():
mini_app_dir.mkdir(parents=True, exist_ok=True)
app.mount("/mini-app", StaticFiles(directory=str(mini_app_dir), html=True), name="mini-app")
def is_allowed(message: Message) -> bool:
return str(message.from_user.id) in ALLOWED_USERS or str(message.chat.id) in ALLOWED_USERS
def _resolve_cli() -> str:
local_agy = "/root/.local/bin/agy"
if os.path.exists(local_agy):
return local_agy
return shutil.which("agy") or shutil.which("gemini") or "agy"
async def _kill_process_tree(pid: int):
if sys.platform != "win32":
try:
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
for _ in range(30):
await asyncio.sleep(0.1)
try:
os.killpg(pgid, 0)
except ProcessLookupError:
return
os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
logging.error(f"Error killing process group {pid}: {e}")
else:
import subprocess
try:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
except Exception as e:
logging.error(f"Error killing Windows process tree {pid}: {e}")
async def _run_with_timeout(args, timeout, cwd=None, env=None):
kwargs = {}
if sys.platform != "win32":
kwargs["preexec_fn"] = os.setpgrp
proc = await asyncio.create_subprocess_exec(
*args,
cwd=cwd,
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
**kwargs
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return proc.returncode, out, err
except asyncio.TimeoutError:
await _kill_process_tree(proc.pid)
raise
async def get_active_session() -> tuple:
"""Returns (session_id or None, repo_path, task_name, conversation_id).
If no active session exists, it defaults to:
(None, str(SWARM_ROOT), "default", "default")
"""
try:
async with connect_db() as db:
async with db.execute("SELECT value FROM app_settings WHERE key = 'active_session_id'") as cursor:
row = await cursor.fetchone()
active_id = int(row[0]) if row else None
if active_id is not None:
async with db.execute(
"SELECT id, repo_path, task_name, conversation_id FROM sessions WHERE id = ?",
(active_id,)
) as cursor:
s_row = await cursor.fetchone()
if s_row:
return s_row
async with db.execute("SELECT value FROM app_settings WHERE key = 'active_repo_path'") as cursor:
row = await cursor.fetchone()
repo_path = row[0] if row else str(SWARM_ROOT)
return (None, repo_path, "default", "default")
except Exception as e:
logging.error(f"Error in get_active_session: {e}")
return (None, str(SWARM_ROOT), "default", "default")
async def init_db():
async with connect_db() as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS long_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
fact TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_path TEXT NOT NULL,
task_name TEXT NOT NULL,
conversation_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(repo_path, task_name)
)
""")
await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_repo_task ON sessions (repo_path, task_name)")
# Scheduler Tables
await db.execute("""
CREATE TABLE IF NOT EXISTS swarm_tasks (
task_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT UNIQUE NOT NULL,
repo_path TEXT NOT NULL,
cron_expression TEXT NOT NULL,
active INTEGER DEFAULT 1,
last_run_time TEXT
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS swarm_run_sessions (
session_id TEXT PRIMARY KEY,
task_id INTEGER NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
log_file_path TEXT,
digest_summary TEXT,
FOREIGN KEY(task_id) REFERENCES swarm_tasks(task_id)
)
""")
try:
await db.execute("ALTER TABLE chat_history ADD COLUMN session_db_id INTEGER DEFAULT NULL")
except aiosqlite.OperationalError:
pass # already exists
await db.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_session ON chat_history (session_id)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_session_db ON chat_history (session_db_id)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_long_term_memory_session ON long_term_memory (session_id)")
await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ltm_session_fact ON long_term_memory (session_id, fact)")
await db.commit()
async def extract_and_save_ltm(session_id: str, user_text: str, assistant_text: str):
try:
prompt = (
"Analyze the following conversation exchange between a User and the CEO. "
"Extract new, persistent facts or preferences about the user, their goals, or requirements. "
"Return only the facts as a plain list, one per line (each starting with '-'). "
"If no new facts are present, output only 'None'.\n\n"
f"User: {user_text}\n"
f"CEO: {assistant_text}"
)
session_db_id, repo_path, task_name, active_conversation_id = await get_active_session()
cli = _resolve_cli()
env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}
args = [cli, "-p", prompt]
if active_conversation_id and active_conversation_id != "default":
args.extend(["--conversation", active_conversation_id])
try:
returncode, out, err = await _run_with_timeout(
args, timeout=180, cwd=repo_path, env=env
)
except asyncio.TimeoutError:
logging.error("LTM extraction subprocess timed out.")
return
if returncode != 0:
stderr_text = err.decode("utf-8", errors="replace").strip()
logging.error(f"LTM extraction failed with code {returncode}. Stderr: {stderr_text}")
return
raw_output = out.decode("utf-8", errors="replace")
_SKIP = (
"warning:",
"ripgrep",
"mcp",
"using a terminal",
"fallback",
"falling back",
"256-color",
)
lines = raw_output.splitlines()
content_lines = []
found = False
for line in lines:
clean_line = _ANSI_ESCAPE_RE.sub("", line).strip().lower()
if not found:
if not clean_line or any(p in clean_line for p in _SKIP):
continue
found = True
if _TOOL_CALL_RE.match(_ANSI_ESCAPE_RE.sub("", line)):
continue
content_lines.append(line)
cleaned_output = "\n".join(content_lines).strip()
facts = []
for line in cleaned_output.splitlines():
line = line.strip()
if not line or line.lower() == "none":
continue
match = re.match(r"^[-*•\d\.\s]+(.*)$", line)
if match:
fact = match.group(1).strip()
if fact and fact.lower() != "none":
facts.append(fact)
else:
if line:
facts.append(line)
if not facts:
return
async with connect_db() as db:
for fact in facts:
try:
await db.execute(
"INSERT OR IGNORE INTO long_term_memory (session_id, fact) VALUES (?, ?)",
(session_id, fact)
)
except aiosqlite.Error as e:
logging.error(f"Failed to insert fact to DB: {e}")
await db.commit()
except Exception as e:
logging.error(f"Error in extract_and_save_ltm background task: {e}")
def extract_assistant_text_from_protobuf(data: bytes) -> str:
def get_strings(bytes_data):
i = 0
strings = []
while i < len(bytes_data):
tag = 0
shift = 0
while i < len(bytes_data):
b = bytes_data[i]
i += 1
tag |= (b & 0x7f) << shift
if not (b & 0x80):
break
shift += 7
if i >= len(bytes_data):
break
wire_type = tag & 7
if wire_type == 0:
while i < len(bytes_data) and (bytes_data[i] & 0x80):
i += 1
i += 1
elif wire_type == 1:
i += 8
elif wire_type == 2:
length = 0
shift = 0
while i < len(bytes_data):
b = bytes_data[i]
i += 1
length |= (b & 0x7f) << shift
if not (b & 0x80):
break
shift += 7
if i + length > len(bytes_data):
break
val = bytes_data[i:i+length]
try:
s = val.decode('utf-8')
if len(s) > 10 and any(c.isalpha() for c in s):
strings.append(s)
except UnicodeDecodeError:
pass
strings.extend(get_strings(val))
i += length
elif wire_type == 5:
i += 4
return strings
candidates = get_strings(data)
if not candidates:
return ""
return max(candidates, key=len)
async def ask_ceo(session_id: str, text: str) -> str:
"""Call CEO via agy (Antigravity CLI) subprocess. Uses OAuth subscription, no API key needed."""
session_db_id, repo_path, task_name, active_conversation_id = await get_active_session()
try:
async with connect_db() as db:
await db.execute(
"INSERT INTO chat_history (session_id, role, text, session_db_id) VALUES (?, ?, ?, ?)",
(session_id, "user", text, session_db_id)
)
await db.commit()
except Exception as db_err:
logging.error(f"Database error saving user message: {db_err}")
history_rows = []
ltm_facts = []
try:
async with connect_db() as db:
async with db.execute(
"SELECT role, text FROM (SELECT role, text, id FROM chat_history WHERE session_id = ? AND session_db_id IS ? ORDER BY id DESC LIMIT 20) ORDER BY id ASC",
(session_id, session_db_id)
) as cursor:
history_rows = await cursor.fetchall()
async with db.execute(
"SELECT fact FROM long_term_memory WHERE session_id = ? ORDER BY id ASC",
(session_id,)
) as cursor:
ltm_rows = await cursor.fetchall()
ltm_facts = [r[0] for r in ltm_rows]
except Exception as db_err:
logging.error(f"Database error retrieving history/LTM: {db_err}")
chat_constraint = (
"\n[SYSTEM INSTRUCTION: You are in CHAT/PLANNING mode. Do NOT write files, modify the codebase, or run executable commands using your tools unless the user explicitly commands you to do so (e.g. using 'write', 'implement', or 'run'). For questions, analysis, planning, and strategy discussions, ONLY reply in text and ask for the user's feedback or approval before making any changes. Keep all modifications strictly blocked until approved.]"
)
prompt_lines = [_CEO_PERSONA, chat_constraint]
if ltm_facts:
prompt_lines.append("\nHere are some persistent facts about this user and conversation:")
for fact in ltm_facts:
prompt_lines.append(f"- {fact}")
prompt_lines.append("")
for role, msg_text in history_rows:
prefix = "User" if role == "user" else "CEO"
prompt_lines.append(f"{prefix}: {msg_text}")
prompt = "\n".join(prompt_lines)
cli = _resolve_cli()
env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}
args = [cli, "--dangerously-skip-permissions", "--print-timeout", "10m", "-p", prompt]
if active_conversation_id and active_conversation_id != "default":
args.extend(["--conversation", active_conversation_id])
try:
returncode, out, err = await _run_with_timeout(
args, timeout=600, cwd=repo_path, env=env
)
except asyncio.TimeoutError:
return "⏱ Превышено время ожидания (10 мин)."
# 1. Attempt to fetch clean response from the official SQLite conversation database
sqlite_response = None
if active_conversation_id and active_conversation_id != "default":
conv_db_path = Path("/root/.gemini/antigravity-cli/conversations") / f"{active_conversation_id}.db"
if not conv_db_path.exists() and sys.platform == "win32":
user_profile = os.environ.get('USERPROFILE') or os.path.expanduser('~')
conv_db_path = Path(user_profile) / ".gemini" / "antigravity-cli" / "conversations" / f"{active_conversation_id}.db"
if conv_db_path.exists():
try:
import sqlite3
# Read the last step of type 14 (model text response)
conn = sqlite3.connect(str(conv_db_path))
cursor = conn.cursor()
cursor.execute(
"SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx DESC LIMIT 1"
)
row = cursor.fetchone()
conn.close()
if row:
sqlite_response = extract_assistant_text_from_protobuf(row[0])
except Exception as db_err:
logging.error(f"Failed to read clean response from SQLite DB: {db_err}")
if sqlite_response:
response = sqlite_response
else:
# Fall back to stdout parsing if the database method failed or conversation is not set
raw = out.decode("utf-8", errors="replace")
_SKIP = (
"warning:",
"ripgrep",
"mcp",
"using a terminal",
"fallback",
"falling back",
"256-color",
)
lines = raw.splitlines()
content_lines = []
found = False
for line in lines:
clean_line = _ANSI_ESCAPE_RE.sub("", line).strip().lower()
if not found:
if not clean_line or any(p in clean_line for p in _SKIP):
continue
found = True
if _TOOL_CALL_RE.match(_ANSI_ESCAPE_RE.sub("", line)):
continue
content_lines.append(line)
response = "\n".join(content_lines).strip()
if not response:
stderr_text = err.decode("utf-8", errors="replace").strip()
logging.error(f"CEO stderr: {stderr_text[:500]}")
if "RESOURCE_EXHAUSTED" in stderr_text or "Individual quota" in stderr_text:
resets_match = re.search(r"Resets in ([\d]+h[\d]+m[\d]+s|[\d]+h[\d]+m|[\d]+m[\d]+s)", stderr_text)
resets_in = resets_match.group(1) if resets_match else "~24h (midnight PT)"
return (
f"⏳ <b>Квота Gemini исчерпана</b>\n"
f"Сброс через: <code>{resets_in}</code>\n\n"
f"Используй /quota для статуса."
)
return f"❌ Нет ответа от модели."
try:
async with connect_db() as db:
await db.execute(
"INSERT INTO chat_history (session_id, role, text, session_db_id) VALUES (?, ?, ?, ?)",
(session_id, "model", response, session_db_id)
)
# Automatic rotation of chat history: keep only latest 200 records per session
await db.execute("""
DELETE FROM chat_history
WHERE session_id = ? AND session_db_id IS ? AND id NOT IN (
SELECT id FROM chat_history
WHERE session_id = ? AND session_db_id IS ?
ORDER BY id DESC LIMIT ?
)
""", (session_id, session_db_id, session_id, session_db_id, _MAX_DB_HISTORY_LIMIT))
await db.commit()
except Exception as db_err:
logging.error(f"Database error saving CEO response: {db_err}")
return response
@dp.message(Command("start"))
async def cmd_start(message: Message):
if not is_allowed(message):
return
await message.answer(
"👋 Привет! Я CEO Antigravity Swarm.\n\n"
"Пиши мне на русском — отвечаю напрямую, без задержек.\n\n"
"Чтобы запустить рой над репозиторием, скинь GitHub-ссылку и задачу:\n"
"<i>«Вот репо https://github.com/user/repo — исправь баги»</i>\n\n"
"Команды:\n"
"🔹 <code>/deploy</code> — Обновить код роя с GitHub\n"
"🔹 <code>/status</code> — Статус задач\n"
"🔹 <code>/log <job_id></code> — Просмотр логов по ID задачи\n"
"🔹 <code>/cleanup</code> — Удалить временные рабочие папки\n"
"🔹 <code>/repos</code> — Список репозиториев роя\n"
"🔹 <code>/repo <path/url></code> — Сменить активный репозиторий\n"
"🔹 <code>/sessions</code> — Список задач текущего репозитория\n"
"🔹 <code>/new <task-name></code> — Создать изолированную задачу\n"
"🔹 <code>/switch <task-name></code> — Переключиться на задачу\n"
"🔹 <code>/where</code> — Текущий контекст репозитория и задачи\n"
"🔹 <code>/task <git-url> <описание></code> — Запустить рой с изоляцией\n"
"🔹 <code>/remember [факт]</code> — Запомнить факт (или извлечь из последнего сообщения)",
parse_mode="HTML",
)
@dp.message(Command("remember"))
async def cmd_remember(message: Message):
if not is_allowed(message):
return
args = message.text.split(maxsplit=1)
session_id = str(message.chat.id)
if len(args) < 2:
try:
session_db_id, _, _, _ = await get_active_session()
async with connect_db() as db:
async with db.execute(
"SELECT role, text FROM chat_history WHERE session_id = ? AND session_db_id IS ? ORDER BY id DESC LIMIT 2",
(session_id, session_db_id)
) as cursor:
rows = await cursor.fetchall()
if len(rows) < 2:
await message.answer("ℹ️ Нет достаточного количества сообщений для извлечения фактов.")
return
user_msg = ""
model_msg = ""
for r, t in rows:
if r == "user":
user_msg = t
elif r == "model":
model_msg = t
if not user_msg or not model_msg:
await message.answer("ℹ️ Не удалось найти пару сообщение-ответ в истории.")
return
await message.answer("🧠 Анализирую последний диалог для извлечения фактов...")
await extract_and_save_ltm(session_id, user_msg, model_msg)
await message.answer("✅ Новые факты успешно извлечены и сохранены!")
except Exception as e:
await message.answer(f"❌ Ошибка извлечения: {e}")
return
fact = args[1].strip()
escaped_fact = html.escape(fact)
try:
async with connect_db() as db:
await db.execute(
"INSERT OR IGNORE INTO long_term_memory (session_id, fact) VALUES (?, ?)",
(session_id, fact)
)
await db.commit()
await message.answer(f"🧠 Запомнил факт: <code>{escaped_fact}</code>", parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка сохранения факта: {e}")
@dp.message(Command("repos"))
async def cmd_repos(message: Message):
if not is_allowed(message):
return
try:
async with connect_db() as db:
async with db.execute("SELECT DISTINCT repo_path FROM sessions ORDER BY repo_path") as cursor:
rows = await cursor.fetchall()
if not rows:
await message.answer("📂 Нет сохранённых репозиториев.")
return
lines = ["📂 <b>Репозитории роя:</b>"]
for r in rows:
lines.append(f"• <code>{r[0]}</code>")
await message.answer("\n".join(lines), parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка получения репозиториев: {e}")
@dp.message(Command("repo"))
async def cmd_repo(message: Message):
if not is_allowed(message):
return
args = message.text.split(maxsplit=1)
if len(args) < 2:
await message.answer("ℹ️ Использование: <code>/repo путь_или_ссылка</code>\nПример: <code>/repo https://github.com/user/repo</code>", parse_mode="HTML")
return
target = args[1].strip()
# Handle GitHub URL
git_match = _GITHUB_REPO_RE.match(target)
if git_match:
repo_name = target.rstrip("/").split("/")[-1].removesuffix(".git")
repo_path = str(Path("/root/workspaces") / repo_name)
elif _GIT_URL_RE.match(target):
repo_name = target.rstrip("/").split("/")[-1].removesuffix(".git")
repo_path = str(Path("/root/workspaces") / repo_name)
else:
repo_path = str(Path(target).resolve())
# Validate that local path exists and is a directory to prevent invalid states
resolved_path = Path(repo_path)
if not resolved_path.exists() or not resolved_path.is_dir():
await message.answer(f"❌ Путь не существует или не является директорией: <code>{repo_path}</code>", parse_mode="HTML")
return
try:
async with connect_db() as db:
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_repo_path', ?)",
(repo_path,)
)
# Remove active_session_id to fall back to default for the new repo
await db.execute(
"DELETE FROM app_settings WHERE key = 'active_session_id'"
)
await db.commit()
await message.answer(f"📁 Активный репозиторий изменен на:\n<code>{repo_path}</code>", parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка смены репозитория: {e}")
@dp.message(Command("sessions"))
async def cmd_sessions(message: Message):
if not is_allowed(message):
return
try:
# Get active repo
session_db_id, active_repo_path, task_name, active_conversation_id = await get_active_session()
async with connect_db() as db:
async with db.execute(
"SELECT id, task_name, conversation_id FROM sessions WHERE repo_path = ? ORDER BY id DESC",
(active_repo_path,)
) as cursor:
rows = await cursor.fetchall()
if not rows:
await message.answer(f"📝 В репозитории <code>{active_repo_path}</code> нет созданных задач.", parse_mode="HTML")
return
lines = [f"📝 <b>Задачи в {active_repo_path}:</b>"]
for r_id, t_name, c_id in rows:
is_active = " 🟢 [Активная]" if r_id == session_db_id else ""
lines.append(f"• ID: <code>{r_id}</code> | <code>{t_name}</code> (conv: <code>{c_id[:8]}...</code>){is_active}")
await message.answer("\n".join(lines), parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка получения списка сессий: {e}")
@dp.message(Command("new"))
async def cmd_new(message: Message):
if not is_allowed(message):
return
args = message.text.split(maxsplit=1)
if len(args) < 2:
await message.answer("ℹ️ Использование: <code>/new имя_задачи</code>\nПример: <code>/new расписание</code>", parse_mode="HTML")
return
task_name = args[1].strip()
escaped_task_name = html.escape(task_name)
try:
session_db_id, active_repo_path, _, _ = await get_active_session()
async with connect_db() as db:
async with db.execute(
"SELECT id FROM sessions WHERE repo_path = ? AND task_name = ?",
(active_repo_path, task_name)
) as cursor:
row = await cursor.fetchone()
if row:
await message.answer(f"⚠️ Задача <code>{escaped_task_name}</code> уже существует. Используйте <code>/switch {escaped_task_name}</code>", parse_mode="HTML")
return
conv_id = str(uuid.uuid4())
cursor = await db.execute(
"INSERT INTO sessions (repo_path, task_name, conversation_id) VALUES (?, ?, ?)",
(active_repo_path, task_name, conv_id)
)
new_id = cursor.lastrowid
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
(str(new_id),)
)
await db.commit()
await message.answer(
f"✨ Создана и активирована задача: <code>{escaped_task_name}</code>\n"
f"🆔 ID задачи (в базе): <code>{new_id}</code>\n"
f"🔑 Conversation ID: <code>{conv_id}</code>",
parse_mode="HTML"
)
except Exception as e:
await message.answer(f"❌ Ошибка создания задачи: {e}")
@dp.message(Command("switch"))
async def cmd_switch(message: Message):
if not is_allowed(message):
return
args = message.text.split(maxsplit=1)
if len(args) < 2:
await message.answer("ℹ️ Использование: <code>/switch имя_задачи</code>\nПример: <code>/switch расписание</code>", parse_mode="HTML")
return
task_name = args[1].strip()
escaped_task_name = html.escape(task_name)
try:
_, active_repo_path, _, _ = await get_active_session()
async with connect_db() as db:
async with db.execute(
"SELECT id FROM sessions WHERE repo_path = ? AND task_name = ?",
(active_repo_path, task_name)
) as cursor:
row = await cursor.fetchone()
if not row:
await message.answer(
f"❌ Задача <code>{escaped_task_name}</code> не найдена для текущего репозитория.\n"
f"Создайте её с помощью <code>/new {escaped_task_name}</code>",
parse_mode="HTML"
)
return
new_id = row[0]
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
(str(new_id),)
)
await db.commit()
await message.answer(f"🔄 Переключено на задачу <code>{escaped_task_name}</code> (ID: <code>{new_id}</code>).", parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка переключения задачи: {e}")
@dp.message(Command("where"))
async def cmd_where(message: Message):
if not is_allowed(message):
return
try:
session_db_id, active_repo_path, task_name, active_conversation_id = await get_active_session()
lines = [
"📍 <b>Текущий контекст:</b>",
f"• Репозиторий: <code>{active_repo_path}</code>",
f"• ID задачи (в базе): <code>{session_db_id or 'нет'}</code>",
f"• Задача: <code>{task_name}</code>",
f"• Conversation ID: <code>{active_conversation_id}</code>"
]
await message.answer("\n".join(lines), parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка получения контекста: {e}")
@dp.message(Command("quota"))
async def cmd_quota(message: Message):
if not is_allowed(message):
return
log_dir = Path("/root/.gemini/antigravity-cli/log")
try:
logs = sorted(log_dir.glob("cli-*.log"), reverse=True)
if not logs:
await message.answer("📊 Лог файлов agy не найдено.")
return
content = logs[0].read_text(errors="replace")
matches = list(re.finditer(
r"RESOURCE_EXHAUSTED.*?Resets in ([\dhms ]+)",
content
))
if matches:
resets_in = matches[-1].group(1).strip()
await message.answer(
f"⏳ <b>Квота Gemini исчерпана</b>\n"
f"Сброс через: <code>{resets_in}</code>\n"
f"Лог: <code>{logs[0].name}</code>",
parse_mode="HTML"
)
else:
await message.answer(
f"✅ <b>Квота в норме</b>\n"
f"429 ошибок в логах не найдено.\n"
f"Лог: <code>{logs[0].name}</code>",
parse_mode="HTML"
)
except Exception as e:
await message.answer(f"❌ Ошибка чтения логов: {e}")
@dp.message(Command("task"))
async def cmd_task(message: Message):
if not is_allowed(message):
return
args = message.text.split(maxsplit=2)
if len(args) < 3:
await message.answer("ℹ️ Использование: <code>/task ссылка_на_гит описание_задачи</code>\nПример: <code>/task https://github.com/user/repo исправить баги</code>", parse_mode="HTML")
return
git_url = args[1].strip()
task_desc = args[2].strip()
await run_task_logic(message, git_url, task_desc)
@dp.message(Command("status"))
async def cmd_status(message: Message):
if not is_allowed(message):
return
async with _registry_lock:
running = list(_task_registry.values())
if running:
lines = [f"🟢 Активных задач: {len(running)}"]
for job in running:
lines.append(f"• job <code>{job['job_id']}</code> PID <code>{job['pid']}</code> — {job['repo_url']}")
await message.answer("\n".join(lines), parse_mode="HTML")
else:
status_path = SWARM_ROOT / "SWARM_STATUS.md"
if status_path.exists():
try:
content = read_cached_file(status_path)
if len(content) > 3800:
content = content[:3800] + "\n\n...(обрезано)"
await message.answer(f"📋 <b>Статус роя:</b>\n\n<pre>{content}</pre>", parse_mode="HTML")
except Exception as e:
await message.answer(f"❌ Ошибка: {e}")
else:
await message.answer("ℹ️ Нет активных задач.")
@dp.message(Command("log"))
async def cmd_log(message: Message):
if not is_allowed(message):
return
args = message.text.split()
if len(args) < 2:
async with _registry_lock:
running = list(_task_registry.values())
if running:
job_id = running[-1]["job_id"]
else:
await message.answer("ℹ️ Использование: <code>/log <job_id></code> или <code>/log <repo_name></code>", parse_mode="HTML")
return
else:
job_id = args[1].strip()
workspace_path = None
async with _registry_lock:
if job_id in _task_registry:
workspace_path = _task_registry[job_id]["workspace_path"]
if not workspace_path:
workspaces_dir = Path("/root/workspaces")
if workspaces_dir.exists():
for d in workspaces_dir.iterdir():
if d.is_dir() and d.name.lower() == job_id.lower():
workspace_path = str(d)
break
if not workspace_path:
await message.answer(f"❌ Задача или репозиторий <code>{job_id}</code> не найдены.", parse_mode="HTML")
return
log_file = Path(workspace_path) / "swarm.log"
telemetry_log = Path(workspace_path) / ".agents" / "telemetry.log"
response = []
if log_file.exists():
try:
content = log_file.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
tail = "\n".join(lines[-35:])
if len(tail) > 3000:
tail = "..." + tail[-3000:]
response.append(f"📋 <b>Последние строки swarm.log:</b>\n<pre>{tail}</pre>")
except Exception as e:
response.append(f"❌ Ошибка чтения swarm.log: {e}")
else:
response.append("ℹ️ Файл <code>swarm.log</code> не найден.")
if telemetry_log.exists():
try:
content = telemetry_log.read_text(encoding="utf-8", errors="replace")
lines = [line for line in content.splitlines() if line.strip()]
tail = "\n".join(lines[-15:])
if len(tail) > 1000:
tail = "..." + tail[-1000:]
response.append(f"📊 <b>Последние строки telemetry.log:</b>\n<pre>{tail}</pre>")
except Exception as e:
response.append(f"❌ Ошибка чтения telemetry.log: {e}")
await message.answer("\n\n".join(response), parse_mode="HTML")
async def _run_git(args: list[str], cwd: str) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
*args, cwd=cwd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
return proc.returncode, out.decode("utf-8", errors="replace"), err.decode("utf-8", errors="replace")
async def _delivery_pipeline(chat_id: int, workspace_path: str):
await bot.send_message(chat_id, "🚀 Рой завершил задачу. Запускаю доставку кода...")
rc, _, err = await _run_git(["git", "add", "."], workspace_path)
if rc != 0:
await bot.send_message(chat_id, f"⚠️ git add failed:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
return
rc, _, err = await _run_git(
["git", "commit", "-m", "feat: Auto-implemented by Antigravity Swarm"],
workspace_path,
)
if rc != 0:
await bot.send_message(chat_id, f"ℹ️ Нечего коммитить или ошибка:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
return
rc, branch_name, _ = await _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], workspace_path)
branch_name = branch_name.strip()
rc, _, err = await _run_git(["git", "push", "origin", branch_name], workspace_path)
if rc != 0:
await bot.send_message(chat_id, f"⚠️ git push failed:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
return
rc, pr_url, err = await _run_git(
["gh", "pr", "create",
"--title", "🤖 Auto: Antigravity Swarm",
"--body", "Изменения внесены роем и проверены Quality-Validator."],
workspace_path,
)
if rc == 0:
await bot.send_message(chat_id, f"🎉 Pull Request создан!\n{pr_url.strip()}")
else:
await bot.send_message(chat_id, f"⚠️ PR не создан:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
async def _monitor_process(job_id: str, process: asyncio.subprocess.Process, chat_id: int, workspace_path: str):
mc_path = Path(workspace_path) / "MISSION_CONTROL.md"
last_mc_content = ""
logging.info(f"[{job_id}] Monitoring PID {process.pid}")
while process.returncode is None:
await asyncio.sleep(30)
if mc_path.exists():
try:
current_mc = mc_path.read_text(encoding="utf-8")
if current_mc != last_mc_content:
last_mc_content = current_mc
progress_lines = [
line for line in current_mc.split("\n")
if "[x]" in line or "[/]" in line or "[ ]" in line
]
if progress_lines:
report = "\n".join(progress_lines[-10:])
await bot.send_message(
chat_id,
f"🔄 <b>Прогресс:</b>\n<pre>{report}</pre>",
parse_mode="HTML",
)
except Exception as e:
logging.error(f"MISSION_CONTROL read error: {e}")
exit_code = await process.wait()
logging.info(f"[{job_id}] PID {process.pid} exited with code {exit_code}")
async with _registry_lock:
_task_registry.pop(job_id, None)
if exit_code == 0:
await _delivery_pipeline(chat_id, workspace_path)
else:
await bot.send_message(
chat_id,
f"⚠️ Задача прервана. Код выхода: <code>{exit_code}</code>.",
parse_mode="HTML",
)
# Clean up workspace folder to avoid VPS cluttering
try:
wpath = Path(workspace_path)
if wpath.exists():
shutil.rmtree(wpath)
logging.info(f"[{job_id}] Workspace cleaned up: {workspace_path}")
except Exception as cleanup_err:
logging.error(f"[{job_id}] Failed to clean up workspace {workspace_path}: {cleanup_err}")
async def run_task_logic(message: Message, git_url: str, task_desc: str):
if not _GIT_URL_RE.match(git_url):
await message.answer("❌ Некорректный URL. Поддерживаются только https:// ссылки.")
return
repo_name = git_url.rstrip("/").split("/")[-1].removesuffix(".git")
workspaces_dir = Path("/root/workspaces")
workspaces_dir.mkdir(exist_ok=True)
workspace_path = (workspaces_dir / repo_name).resolve()
if not str(workspace_path).startswith(str(workspaces_dir.resolve())):
await message.answer("❌ Недопустимое имя репозитория.")
return
await message.answer(f"⏳ Клонирую <code>{git_url}</code>...", parse_mode="HTML")
if workspace_path.exists():
shutil.rmtree(workspace_path)
rc, _, clone_err = await _run_git(["git", "clone", "--depth", "1", git_url, str(workspace_path)], "/root")
if rc != 0:
await message.answer(f"❌ Ошибка клонирования:\n<pre>{clone_err[:400]}</pre>", parse_mode="HTML")
return
job_id = str(uuid.uuid4())[:8]
task_name = f"task-{job_id}"
conv_id = str(uuid.uuid4())
# Register session and workspace path in settings
try:
async with connect_db() as db:
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_repo_path', ?)",
(str(workspace_path),)
)
cursor = await db.execute(
"INSERT INTO sessions (repo_path, task_name, conversation_id) VALUES (?, ?, ?)",
(str(workspace_path), task_name, conv_id)
)
session_db_id = cursor.lastrowid
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
(str(session_db_id),)
)
await db.commit()
except Exception as db_err:
logging.error(f"Failed to register task session: {db_err}")
branch_name = f"swarm/task-{job_id}"
rc, _, branch_err = await _run_git(["git", "checkout", "-b", branch_name], str(workspace_path))
if rc != 0:
await message.answer(f"⚠️ Ветка не создана:\n<pre>{branch_err[:200]}</pre>", parse_mode="HTML")
await message.answer(f"🚀 Запускаю рой в ветке <code>{branch_name}</code>...", parse_mode="HTML")
env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}
log_path = workspace_path / "swarm.log"
log_file = None
try:
log_file = open(log_path, "w", encoding="utf-8")
stdout_dest = log_file
stderr_dest = log_file
except Exception as e:
logging.error(f"Failed to open log file {log_path}: {e}")
stdout_dest = asyncio.subprocess.DEVNULL
stderr_dest = asyncio.subprocess.DEVNULL
swarm_prompt = (
f"Ты — CEO Antigravity Swarm. Запущен над репозиторием: {git_url}\n"
f"Задача: {task_desc}\n\n"
f"Следуй SDD pipeline: oracle → prometheus → junior → quality-validator. "
f"Все изменения делай в текущей директории. "
f"По завершении напиши краткий отчёт о сделанных изменениях."
)
kwargs = {}
if sys.platform != "win32":
kwargs["preexec_fn"] = os.setpgrp
process = await asyncio.create_subprocess_exec(
_resolve_cli(), "--print-timeout", "15m", "-p", swarm_prompt, "--conversation", conv_id,
cwd=str(workspace_path),
env=env,
stdin=asyncio.subprocess.DEVNULL,
stdout=stdout_dest,
stderr=stderr_dest,
**kwargs
)
if log_file:
log_file.close()
monitor = asyncio.create_task(
_monitor_process(job_id, process, message.chat.id, str(workspace_path))
)
async with _registry_lock:
_task_registry[job_id] = {
"job_id": job_id,
"chat_id": message.chat.id,
"repo_url": git_url,
"workspace_path": str(workspace_path),
"pid": process.pid,
"status": "running",
"monitor_task": monitor,
}
await message.answer(
f"🤖 Рой запущен! PID: <code>{process.pid}</code> · job: <code>{job_id}</code>\n"
f"📝 {task_desc}\n\nОбновления каждые 30 сек.",
parse_mode="HTML",
)
@dp.message(Command("cleanup"))
async def cmd_cleanup(message: Message):
if not is_allowed(message):
return
async with _registry_lock:
if _task_registry:
await message.answer("⚠️ Есть активные задачи. Дождитесь завершения перед очисткой.")
return
workspaces_dir = Path("/root/workspaces")
if not workspaces_dir.exists():
await message.answer("ℹ️ ~/workspaces пуста.")
return
removed = [d.name for d in workspaces_dir.iterdir() if d.is_dir()]
for d in workspaces_dir.iterdir():
if d.is_dir():
shutil.rmtree(d)
if removed:
await message.answer(
"🗑 Удалены репозитории:\n" + "\n".join(f"• <code>{r}</code>" for r in removed),
parse_mode="HTML",
)
else:
await message.answer("ℹ️ Нечего удалять.")
@dp.message(Command("deploy"))
async def cmd_deploy(message: Message):
if not is_allowed(message):
return
async with _registry_lock:
if _task_registry:
await message.answer("⚠️ Есть активные задачи. Дождитесь завершения перед деплоем.")
return
await message.answer("🔄 Подтягиваю обновления с GitHub (ветка main)...")
rc, pull_out, pull_err = await _run_git(["git", "pull", "origin", "main"], str(SWARM_ROOT))
if rc != 0:
await message.answer(f"❌ git pull failed:\n<pre>{pull_err[:400]}</pre>", parse_mode="HTML")
return
await message.answer(
f"✅ Код обновлён!\n<pre>{pull_out.strip()[:300]}</pre>\n\n⚡ Перезапускаю сервис...",
parse_mode="HTML",
)
try:
async with connect_db() as db:
await db.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES ('pending_deploy_chat_id', ?)",
(str(message.chat.id),)
)
await db.commit()
except Exception as db_err:
logging.error(f"Failed to save pending deploy chat ID: {db_err}")
restart = await asyncio.create_subprocess_exec(
"systemctl", "restart", "tg-bridge.service",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await restart.wait()
@dp.message()
async def fallback_handler(message: Message):
if not is_allowed(message):
return
text = (message.text or "").strip()
if not text:
return
session_id = str(message.chat.id)
git_match = _GITHUB_REPO_RE.search(text)
if git_match:
git_url = f"https://github.com/{git_match.group(1)}/{git_match.group(2)}"
task_desc = _GITHUB_REPO_RE.sub("", text).strip() or "выполни задачу"
await run_task_logic(message, git_url, task_desc)
return
async def _keep_typing(chat_id: int, stop_event: asyncio.Event):
from aiogram.enums import ChatAction
while not stop_event.is_set():
try:
await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
except Exception:
pass
await asyncio.sleep(4)
stop_typing = asyncio.Event()
typing_task = asyncio.create_task(_keep_typing(message.chat.id, stop_typing))
err_msg = None
try:
reply = await ask_ceo(session_id, text)
except Exception as e:
logging.error(f"CEO error: {e}")
reply = None
err_msg = str(e)
finally:
stop_typing.set()
typing_task.cancel()
if reply:
formatted = md_to_tg_html(reply)
for chunk in [formatted[i:i + 4000] for i in range(0, len(formatted), 4000)]:
try:
await message.answer(chunk, parse_mode="HTML")
except Exception as e:
logging.warning(f"HTML send failed ({e}); retrying as plain text")
plain = strip_markdown(reply)
for pchunk in [plain[i:i + 4000] for i in range(0, len(plain), 4000)]:
await message.answer(pchunk)
break
# Batch LTM extraction: check if message count is a multiple of 20
try:
session_db_id, _, _, _ = await get_active_session()
async with connect_db() as db:
async with db.execute(
"SELECT COUNT(*) FROM chat_history WHERE session_id = ? AND session_db_id IS ?",
(session_id, session_db_id)
) as cursor:
row = await cursor.fetchone()
msg_count = row[0] if row else 0
if msg_count > 0 and msg_count % 20 == 0:
logging.info(f"Triggering batch LTM extraction (message count: {msg_count})")
asyncio.create_task(extract_and_save_ltm(session_id, text, reply))
except Exception as e:
logging.error(f"Error checking message count for LTM batch: {e}")
else:
await message.answer(f"❌ Ошибка CEO агента: {err_msg or 'пустой ответ'}")
async def main():
logging.info("Starting Telegram Swarm Bridge & FastAPI...")
await init_db()
# Set Telegram slash command menu
try:
commands = [
BotCommand(command="deploy", description="Обновить код роя с GitHub"),
BotCommand(command="status", description="Статус задач"),
BotCommand(command="log", description="Просмотр логов по ID задачи"),
BotCommand(command="cleanup", description="Удалить временные рабочие папки"),
BotCommand(command="repos", description="Список репозиториев роя"),
BotCommand(command="repo", description="Сменить активный репозиторий"),
BotCommand(command="sessions", description="Список задач текущего репозитория"),
BotCommand(command="new", description="Создать изолированную задачу"),
BotCommand(command="switch", description="Переключиться на задачу"),
BotCommand(command="where", description="Текущий контекст репозитория и задачи"),
BotCommand(command="quota", description="Статус квоты Gemini"),
BotCommand(command="task", description="Запустить рой с изоляцией"),
BotCommand(command="remember", description="Запомнить факт"),
]
await bot.set_my_commands(commands)
logging.info("Telegram commands menu set successfully.")
except Exception as e:
logging.error(f"Failed to set bot commands: {e}")
# Load initial settings
global _MAX_HISTORY
try:
async with connect_db() as db:
async with db.execute("SELECT value FROM app_settings WHERE key = 'max_history_limit'") as cursor:
row = await cursor.fetchone()
if row:
_MAX_HISTORY = int(row[0])
except Exception as e:
logging.error(f"Failed to load initial settings: {e}")
# Check and report pending deploy notification
try:
async with connect_db() as db:
async with db.execute("SELECT value FROM app_settings WHERE key = 'pending_deploy_chat_id'") as cursor:
row = await cursor.fetchone()
pending_chat_id = row[0] if row else None
if pending_chat_id:
await db.execute("DELETE FROM app_settings WHERE key = 'pending_deploy_chat_id'")
await db.commit()
if pending_chat_id:
logging.info(f"Pending deploy chat ID found: {pending_chat_id}. Sending notification...")
async def send_confirmation():
try:
await bot.send_message(
chat_id=pending_chat_id,
text="🚀 <b>Деплой успешно завершен!</b> Бот перезапущен, онлайн и готов к работе.",
parse_mode="HTML"
)
except Exception as send_err:
logging.error(f"Failed to send deploy confirmation: {send_err}")
asyncio.create_task(send_confirmation())
except Exception as e:
logging.error(f"Error checking/sending deploy confirmation: {e}")
import uvicorn
config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="info")
server = uvicorn.Server(config)
loop = asyncio.get_running_loop()
stop_event = asyncio.Event()
def handle_signal():
logging.info("Received stop signal, initiating graceful shutdown...")
stop_event.set()
if sys.platform != "win32":
import signal
loop.add_signal_handler(signal.SIGTERM, handle_signal)
loop.add_signal_handler(signal.SIGINT, handle_signal)
polling_task = asyncio.create_task(dp.start_polling(bot))
server_task = asyncio.create_task(server.serve())
async def wait_for_stop():
await stop_event.wait()
logging.info("Stopping polling and web server...")
await dp.stop_polling()
server.should_exit = True
stop_monitor = asyncio.create_task(wait_for_stop())
try:
tasks = [polling_task, server_task, stop_monitor]
await asyncio.gather(*tasks)
except (KeyboardInterrupt, SystemExit):
logging.info("KeyboardInterrupt/SystemExit caught, shutting down...")
stop_event.set()
await asyncio.sleep(0.5)
except asyncio.CancelledError:
logging.info("Tasks cancelled.")
finally:
try:
async with connect_db() as db:
logging.info("Running PRAGMA optimize...")
await db.execute("PRAGMA optimize")
await db.commit()
except Exception as e:
logging.error(f"Failed to run PRAGMA optimize: {e}")
logging.info("Graceful shutdown complete.")
if __name__ == "__main__":
asyncio.run(main())