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:
9
.dockerignore
Normal file
9
.dockerignore
Normal 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
19
Dockerfile
Normal 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"]
|
||||||
@@ -52,6 +52,7 @@ class Config:
|
|||||||
errors.append("DISCORD_TOKEN is not set")
|
errors.append("DISCORD_TOKEN is not set")
|
||||||
if not cls.DISCORD_GUILD_ID:
|
if not cls.DISCORD_GUILD_ID:
|
||||||
errors.append("DISCORD_GUILD_ID is not set")
|
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}")
|
errors.append(f"BRAIN_PATH does not exist: {cls.BRAIN_PATH}")
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal 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:
|
||||||
@@ -101,7 +101,8 @@ auto-approve | 1741678...
|
|||||||
| 모드 | 설정 | 설명 |
|
| 모드 | 설정 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `local` (기본) | `BOT_MODE=local` | 로컬 파일시스템 bridge 사용 |
|
| `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 ← 명령 폴링 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 트러블슈팅
|
## 트러블슈팅
|
||||||
|
|
||||||
| 증상 | 해결 |
|
| 증상 | 해결 |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ let bridgePath;
|
|||||||
let projectName;
|
let projectName;
|
||||||
let workspaceUri = ''; // filesystem path of the workspace folder (for session filtering)
|
let workspaceUri = ''; // filesystem path of the workspace folder (for session filtering)
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
|
let autoApproveEnabled = false; // toggled via !auto from Discord
|
||||||
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
||||||
let watcher = null;
|
let watcher = null;
|
||||||
let commandsWatcher = null;
|
let commandsWatcher = null;
|
||||||
@@ -186,8 +187,17 @@ function processCommandFile(filePath) {
|
|||||||
}
|
}
|
||||||
else if (text.startsWith('!auto')) {
|
else if (text.startsWith('!auto')) {
|
||||||
// Auto-approve mode toggle
|
// Auto-approve mode toggle
|
||||||
const mode = text.includes('on') ? 'true' : 'false';
|
if (text === '!auto on') {
|
||||||
console.log(`Gravity Bridge: auto-approve → ${mode}`);
|
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) {
|
else if (text) {
|
||||||
// Send message to Antigravity — use VS Code command (most reliable)
|
// Send message to Antigravity — use VS Code command (most reliable)
|
||||||
@@ -1897,6 +1907,12 @@ function setupMonitor() {
|
|||||||
lastPendingStepIndex = actualIndex;
|
lastPendingStepIndex = actualIndex;
|
||||||
lastPendingTime = Date.now();
|
lastPendingTime = Date.now();
|
||||||
sawRunningAfterPending = false;
|
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({
|
writePendingApproval({
|
||||||
conversation_id: activeSessionId,
|
conversation_id: activeSessionId,
|
||||||
command,
|
command,
|
||||||
@@ -1906,6 +1922,7 @@ function setupMonitor() {
|
|||||||
source: 'step_probe_offset',
|
source: 'step_probe_offset',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1953,6 +1970,12 @@ function setupMonitor() {
|
|||||||
lastPendingStepIndex = si;
|
lastPendingStepIndex = si;
|
||||||
lastPendingTime = Date.now();
|
lastPendingTime = Date.now();
|
||||||
sawRunningAfterPending = false;
|
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({
|
writePendingApproval({
|
||||||
conversation_id: activeSessionId,
|
conversation_id: activeSessionId,
|
||||||
command,
|
command,
|
||||||
@@ -1962,6 +1985,7 @@ function setupMonitor() {
|
|||||||
source: 'step_probe',
|
source: 'step_probe',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
173
gateway.py
Normal file
173
gateway.py
Normal 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
17
main.py
@@ -6,6 +6,7 @@ Entry point that runs the brain watcher and Discord bot together.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
@@ -61,6 +62,8 @@ async def main():
|
|||||||
logger.info(f"Remote transport: {Config.REMOTE_BRIDGE_URL}")
|
logger.info(f"Remote transport: {Config.REMOTE_BRIDGE_URL}")
|
||||||
|
|
||||||
# Create components
|
# Create components
|
||||||
|
watcher = None
|
||||||
|
if Config.BOT_MODE != 'gateway':
|
||||||
watcher = BrainWatcher(event_queue, loop)
|
watcher = BrainWatcher(event_queue, loop)
|
||||||
bot = GravityBot(event_queue)
|
bot = GravityBot(event_queue)
|
||||||
|
|
||||||
@@ -70,9 +73,20 @@ async def main():
|
|||||||
bot.bridge = BridgeProtocol(transport)
|
bot.bridge = BridgeProtocol(transport)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start watcher (runs in a separate thread via watchdog)
|
# Start watcher (local mode only — gateway receives data via HTTP)
|
||||||
|
if watcher:
|
||||||
watcher.start()
|
watcher.start()
|
||||||
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
|
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)
|
# Run Discord bot (blocks until bot disconnects)
|
||||||
await bot.start(Config.DISCORD_TOKEN)
|
await bot.start(Config.DISCORD_TOKEN)
|
||||||
@@ -83,6 +97,7 @@ async def main():
|
|||||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
if watcher:
|
||||||
watcher.stop()
|
watcher.stop()
|
||||||
if not bot.is_closed():
|
if not bot.is_closed():
|
||||||
await bot.close()
|
await bot.close()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
discord.py>=2.3.0
|
discord.py>=2.3.0
|
||||||
watchdog>=3.0.0
|
watchdog>=3.0.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user