feat(gateway): Docker Gateway 봇 + HTTP API 구현 #task-311

- gateway.py: Collector↔Gateway HTTP API (pending, response, chat, register, commands)
- Dockerfile + docker-compose.yml: BOT_MODE=gateway, port 8585
- main.py: gateway 모드 (watcher 비활성, GatewayAPI 시작)
- config.py: gateway 모드 BRAIN_PATH 검증 스킵
- requirements.txt: aiohttp 추가
- docs/usage-guide.md: Docker 배포 섹션 추가
- Extension VSIX v0.3.9 빌드 (auto-approve 포함)
This commit is contained in:
Variet Worker
2026-03-11 19:38:26 +09:00
parent c1303999cf
commit 6dbbb57fa7
10 changed files with 326 additions and 27 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.env
*.vsix
extension/node_modules/
extension/out/
.deps_installed
gravity_control.log
*.tar.gz

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt aiohttp>=3.9.0
# Copy application code
COPY config.py bridge.py bot.py watcher.py gateway.py main.py ./
COPY .env* ./
# Default environment (can be overridden via docker-compose)
ENV BOT_MODE=gateway
ENV GATEWAY_PORT=8585
EXPOSE 8585
CMD ["python", "main.py"]

View File

@@ -52,6 +52,7 @@ class Config:
errors.append("DISCORD_TOKEN is not set")
if not cls.DISCORD_GUILD_ID:
errors.append("DISCORD_GUILD_ID is not set")
if not cls.BRAIN_PATH.exists():
# Gateway mode doesn't need local BRAIN_PATH
if cls.BOT_MODE != 'gateway' and not cls.BRAIN_PATH.exists():
errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}")
return errors

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
gateway:
build: .
container_name: gravity-gateway
restart: unless-stopped
ports:
- "8585:8585"
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- BOT_MODE=gateway
- GATEWAY_PORT=8585
# Brain path inside container (not used in gateway mode, but needed for config validation)
- BRAIN_PATH=/app/data/brain
volumes:
- gateway-data:/app/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
gateway-data:

View File

@@ -101,7 +101,8 @@ auto-approve | 1741678...
| 모드 | 설정 | 설명 |
|------|------|------|
| `local` (기본) | `BOT_MODE=local` | 로컬 파일시스템 bridge 사용 |
| `remote` (미래) | `BOT_MODE=remote` | HTTP로 원격 bridge 폴링 (미구현) |
| `remote` (미래) | `BOT_MODE=remote` | HTTP로 원격 bridge 폴링 (Collector 모드) |
| `gateway` | `BOT_MODE=gateway` | 서버에서 Discord 통신 + HTTP API (Docker용) |
---
@@ -118,6 +119,38 @@ DEBOUNCE_SECONDS=2 # 이벤트 디바운스 (초)
---
## Docker 배포 (Gateway)
서버에서 Gateway 봇을 Docker로 실행:
```
[로컬 PC] [서버 Docker]
Extension → bridge/ ← 로컬 Bot ──HTTP──→ Gateway :8585 ←→ Discord
(Collector)
```
### 서버에서 실행
```bash
git clone https://git.variet.net/Variet/gravity_control.git
cd gravity_control
cp .env.example .env # DISCORD_TOKEN, DISCORD_GUILD_ID 입력
docker compose up -d
docker compose logs -f # 로그 확인
```
### Gateway API
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/health` | 헬스체크 |
| POST | `/api/pending` | Collector → 승인 요청 |
| GET | `/api/response/{rid}` | Collector ← 승인 응답 |
| POST | `/api/chat` | Collector → 채팅 스냅샷 |
| GET | `/api/commands/{project}` | Collector ← 명령 폴링 |
---
## 트러블슈팅
| 증상 | 해결 |

View File

@@ -77,6 +77,7 @@ let bridgePath;
let projectName;
let workspaceUri = ''; // filesystem path of the workspace folder (for session filtering)
let isActive = false;
let autoApproveEnabled = false; // toggled via !auto from Discord
let deterministicPort = 0; // derived from projectName, consistent across restarts
let watcher = null;
let commandsWatcher = null;
@@ -186,8 +187,17 @@ function processCommandFile(filePath) {
}
else if (text.startsWith('!auto')) {
// Auto-approve mode toggle
const mode = text.includes('on') ? 'true' : 'false';
console.log(`Gravity Bridge: auto-approve${mode}`);
if (text === '!auto on') {
autoApproveEnabled = true;
}
else if (text === '!auto off') {
autoApproveEnabled = false;
}
else {
// Toggle if no explicit on/off
autoApproveEnabled = !autoApproveEnabled;
}
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
}
else if (text) {
// Send message to Antigravity — use VS Code command (most reliable)
@@ -1897,6 +1907,12 @@ function setupMonitor() {
lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
// Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex);
}
else {
writePendingApproval({
conversation_id: activeSessionId,
command,
@@ -1906,6 +1922,7 @@ function setupMonitor() {
source: 'step_probe_offset',
});
}
}
break;
}
}
@@ -1953,6 +1970,12 @@ function setupMonitor() {
lastPendingStepIndex = si;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
// Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si);
}
else {
writePendingApproval({
conversation_id: activeSessionId,
command,
@@ -1962,6 +1985,7 @@ function setupMonitor() {
source: 'step_probe',
});
}
}
break;
}
}

File diff suppressed because one or more lines are too long

173
gateway.py Normal file
View File

@@ -0,0 +1,173 @@
"""Gateway HTTP API — receives data from remote Collectors and routes to Discord bot.
Runs alongside the Discord bot in the server Docker container.
Collectors (local PCs) push pending approvals, chat snapshots, and registrations
to this API, and poll for responses.
Endpoints:
POST /api/pending — Collector pushes a new approval request
GET /api/pending — List all pending requests (for diagnostics)
POST /api/response/{rid} — Collector polls for response (or Gateway pushes)
GET /api/response/{rid} — Get response for a specific request
POST /api/chat — Collector pushes a chat snapshot
POST /api/register — Collector registers session → project mapping
POST /api/command — Gateway pushes command to specific collector
GET /api/commands/{project} — Collector polls for commands
GET /health — Health check
"""
import asyncio
import json
import time
import logging
from aiohttp import web
logger = logging.getLogger(__name__)
class GatewayAPI:
"""HTTP API server for Collector ↔ Gateway communication."""
def __init__(self, bot, host: str = "0.0.0.0", port: int = 8585):
self.bot = bot
self.host = host
self.port = port
self.app = web.Application()
self._setup_routes()
# In-memory stores (Gateway is stateless across restarts)
self._commands: dict[str, list[dict]] = {} # project → [command dicts]
def _setup_routes(self):
self.app.router.add_get("/health", self._health)
self.app.router.add_post("/api/pending", self._post_pending)
self.app.router.add_get("/api/pending", self._list_pending)
self.app.router.add_get("/api/response/{rid}", self._get_response)
self.app.router.add_post("/api/chat", self._post_chat)
self.app.router.add_post("/api/register", self._post_register)
self.app.router.add_get("/api/commands/{project}", self._get_commands)
# ─── Health ───
async def _health(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
"bot_ready": self.bot.is_ready() if self.bot else False,
"timestamp": time.time(),
})
# ─── Pending Approvals (Collector → Gateway → Discord) ───
async def _post_pending(self, request: web.Request) -> web.Response:
"""Collector pushes a pending approval request."""
try:
data = await request.json()
rid = data.get("request_id", str(int(time.time() * 1000)))
data["request_id"] = rid
data.setdefault("timestamp", time.time())
data.setdefault("status", "pending")
# Write to bridge pending dir (bot's scanner will pick it up)
self.bot.bridge.transport.write_json("pending", f"{rid}.json", data)
logger.info(f"[GATEWAY] pending received: {rid[:12]} project={data.get('project_name', '?')}")
return web.json_response({"ok": True, "request_id": rid})
except Exception as e:
logger.error(f"[GATEWAY] pending error: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=400)
async def _list_pending(self, request: web.Request) -> web.Response:
"""List all pending requests (diagnostics)."""
requests = self.bot.bridge.get_pending_requests()
return web.json_response([{
"request_id": r.request_id,
"command": r.command[:100],
"project_name": r.project_name,
"status": r.status,
} for r in requests])
# ─── Responses (Discord → Gateway → Collector) ───
async def _get_response(self, request: web.Request) -> web.Response:
"""Collector polls for a response to a specific pending request."""
rid = request.match_info["rid"]
data = self.bot.bridge.transport.read_json("response", f"{rid}.json")
if data is None:
return web.json_response({"waiting": True, "request_id": rid})
# Serve response and delete file (one-time consumption)
self.bot.bridge.transport.delete_file("response", f"{rid}.json")
return web.json_response(data)
# ─── Chat Snapshots (Collector → Gateway → Discord) ───
async def _post_chat(self, request: web.Request) -> web.Response:
"""Collector pushes a chat snapshot for relay to Discord."""
try:
data = await request.json()
project = data.get("project_name", "")
content = data.get("content", "")
if not project or not content:
return web.json_response({"ok": False, "error": "project_name and content required"}, status=400)
# Write to chat_snapshots dir for bot's scanner
snap_dir = self.bot.bridge.transport.bridge_dir / "chat_snapshots" if hasattr(self.bot.bridge.transport, 'bridge_dir') else None
if snap_dir:
snap_dir.mkdir(parents=True, exist_ok=True)
snap_id = f"{int(time.time() * 1000)}"
snap_data = {
"id": snap_id,
"project_name": project,
"content": content,
"timestamp": time.time(),
}
(snap_dir / f"{snap_id}.json").write_text(
json.dumps(snap_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(f"[GATEWAY] chat received: project={project} len={len(content)}")
return web.json_response({"ok": True})
except Exception as e:
logger.error(f"[GATEWAY] chat error: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=400)
# ─── Registration (Collector → Gateway) ───
async def _post_register(self, request: web.Request) -> web.Response:
"""Collector registers session → project mapping."""
try:
data = await request.json()
session_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if session_id and project:
self.bot.conv_to_project[session_id] = project
logger.info(f"[GATEWAY] registered: {session_id[:8]}{project}")
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
# ─── Commands (Gateway → Collector) ───
async def _get_commands(self, request: web.Request) -> web.Response:
"""Collector polls for commands (e.g. !auto, !stop, text messages)."""
project = request.match_info["project"]
commands = self._commands.pop(project, [])
return web.json_response({"commands": commands})
def push_command(self, project: str, command: dict):
"""Bot pushes a command for a Collector to pick up."""
if project not in self._commands:
self._commands[project] = []
self._commands[project].append(command)
# ─── Run ───
async def start(self):
"""Start the HTTP server."""
runner = web.AppRunner(self.app)
await runner.setup()
site = web.TCPSite(runner, self.host, self.port)
await site.start()
logger.info(f"[GATEWAY] HTTP API started on {self.host}:{self.port}")

17
main.py
View File

@@ -6,6 +6,7 @@ Entry point that runs the brain watcher and Discord bot together.
import asyncio
import io
import logging
import os
import sys
from config import Config
@@ -61,6 +62,8 @@ async def main():
logger.info(f"Remote transport: {Config.REMOTE_BRIDGE_URL}")
# Create components
watcher = None
if Config.BOT_MODE != 'gateway':
watcher = BrainWatcher(event_queue, loop)
bot = GravityBot(event_queue)
@@ -70,9 +73,20 @@ async def main():
bot.bridge = BridgeProtocol(transport)
try:
# Start watcher (runs in a separate thread via watchdog)
# Start watcher (local mode only — gateway receives data via HTTP)
if watcher:
watcher.start()
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
else:
logger.info("Gateway mode — watcher disabled (data via HTTP API)")
# Start Gateway HTTP API (gateway mode)
if Config.BOT_MODE == 'gateway':
from gateway import GatewayAPI
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585'))
gateway = GatewayAPI(bot, port=gateway_port)
await gateway.start()
logger.info(f"Gateway API running on port {gateway_port}")
# Run Discord bot (blocks until bot disconnects)
await bot.start(Config.DISCORD_TOKEN)
@@ -83,6 +97,7 @@ async def main():
logger.error(f"Fatal error: {e}", exc_info=True)
finally:
# Cleanup
if watcher:
watcher.stop()
if not bot.is_closed():
await bot.close()

View File

@@ -1,3 +1,4 @@
discord.py>=2.3.0
watchdog>=3.0.0
python-dotenv>=1.0.0
aiohttp>=3.9.0