feat(bot,bridge): P1 !auto 토글 자동승인 + P2 BridgeTransport 추상화 #task-304 #task-305
P1: !auto 토글 (bot.py + extension.ts)
- auto_approve_projects set으로 프로젝트별 상태 관리
- !auto → on/off 토글, pending 자동 승인 + 🤖 자동 승인됨 embed
- Extension step_probe에서 autoApproveEnabled 시 직접 tryApprovalStrategies
P2: BridgeTransport 추상화 (bridge.py)
- BridgeTransport ABC + LocalTransport (기존 동작 100% 호환)
- RemoteTransport 스켈레톤 (multi-PC 대비)
- config.py BOT_MODE/REMOTE_BRIDGE_URL, main.py transport 주입
docs: usage-guide.md + tech-stack.md Python 경로 기록
This commit is contained in:
@@ -4,34 +4,43 @@
|
|||||||
|
|
||||||
## 언어 & 런타임
|
## 언어 & 런타임
|
||||||
|
|
||||||
| 항목 | 버전 | 비고 |
|
| 항목 | 버전 | 경로/비고 |
|
||||||
|------|------|------|
|
|------|------|-----------|
|
||||||
| (예: Node.js) | (예: 20.x) | (설치 경로 등) |
|
| Python | 3.x (miniforge3) | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe` |
|
||||||
| (예: Python) | (예: 3.12) | (가상환경 경로 등) |
|
| Node.js | 시스템 설치 | `node`, `npm` (PowerShell에서 `cmd /c npm` 권장) |
|
||||||
|
| TypeScript | (Extension) | `extension/src/extension.ts` → `tsc` 빌드 |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Python은 **반드시** 위 miniforge3 경로를 사용. WindowsApps의 python stub은 동작하지 않음.
|
||||||
|
|
||||||
## 프레임워크
|
## 프레임워크
|
||||||
|
|
||||||
| 항목 | 버전 | 용도 |
|
| 항목 | 버전 | 용도 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| (예: Express) | (예: 4.18) | (서버) |
|
| discord.py | 2.x | Discord 봇 |
|
||||||
| (예: React) | (예: 18.x) | (프론트엔드) |
|
| watchdog | - | 파일시스템 감시 |
|
||||||
|
| antigravity-sdk | 로컬 | VS Code Extension SDK 연동 |
|
||||||
|
|
||||||
## 패키지 관리
|
## 패키지 관리
|
||||||
|
|
||||||
- 패키지 매니저: (npm / yarn / pnpm / pip 등)
|
- **Python**: pip (`requirements.txt`)
|
||||||
- Lock 파일: (package-lock.json / yarn.lock 등)
|
- **Extension**: npm (`extension/package.json`)
|
||||||
|
|
||||||
## 개발 도구
|
## 개발 도구
|
||||||
|
|
||||||
| 도구 | 명령어 |
|
| 도구 | 명령어 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 개발 서버 | (예: `cmd /c npm run dev`) |
|
| **봇 실행** | `start_bot.bat` 또는 `C:\ProgramData\miniforge3\envs\gravity_control\python.exe main.py` |
|
||||||
| 빌드 | (예: `cmd /c npm run build`) |
|
| **Extension 빌드** | `cd extension && cmd /c npm run compile` |
|
||||||
| 테스트 | (예: `cmd /c npm test`) |
|
| **Extension VSIX** | `cd extension && cmd /c npx vsce package` |
|
||||||
| 린트 | (예: `cmd /c npm run lint`) |
|
| **봇 구문 검사** | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe -c "import bot, bridge, config, main"` |
|
||||||
|
|
||||||
## 환경 변수
|
## 환경 변수
|
||||||
|
|
||||||
| 변수명 | 용도 | 기본값 |
|
| 변수명 | 용도 | 기본값 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| (예: PORT) | (서버 포트) | (3000) |
|
| DISCORD_TOKEN | Discord 봇 토큰 | (필수) |
|
||||||
|
| DISCORD_GUILD_ID | Discord 서버 ID | (필수) |
|
||||||
|
| BRAIN_PATH | AG 브레인 경로 | `~/.gemini/antigravity/brain` |
|
||||||
|
| BOT_MODE | 봇 모드 (local/remote) | `local` |
|
||||||
|
| REMOTE_BRIDGE_URL | 원격 브릿지 URL | (remote 모드 전용) |
|
||||||
|
|||||||
62
bot.py
62
bot.py
@@ -179,6 +179,7 @@ class GravityBot(commands.Bot):
|
|||||||
self.bridge = BridgeProtocol()
|
self.bridge = BridgeProtocol()
|
||||||
self.session_category: discord.CategoryChannel | None = None
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
self.guild: discord.Guild | None = None
|
self.guild: discord.Guild | None = None
|
||||||
|
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_channel_name(project_name: str) -> str:
|
def _make_channel_name(project_name: str) -> str:
|
||||||
@@ -211,12 +212,18 @@ class GravityBot(commands.Bot):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@self.tree.command(name="auto", description="자동 승인 토글")
|
@self.tree.command(name="auto", description="자동 승인 토글")
|
||||||
async def slash_auto(interaction: discord.Interaction, mode: str):
|
async def slash_auto(interaction: discord.Interaction):
|
||||||
project = self.channel_to_project.get(interaction.channel_id)
|
project = self.channel_to_project.get(interaction.channel_id)
|
||||||
if not project:
|
if not project:
|
||||||
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
||||||
return
|
return
|
||||||
enabled = mode.lower() in ("on", "true", "1")
|
# Toggle
|
||||||
|
if project in self.auto_approve_projects:
|
||||||
|
self.auto_approve_projects.discard(project)
|
||||||
|
enabled = False
|
||||||
|
else:
|
||||||
|
self.auto_approve_projects.add(project)
|
||||||
|
enabled = True
|
||||||
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
||||||
emoji = "🟢" if enabled else "🔴"
|
emoji = "🟢" if enabled else "🔴"
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
@@ -521,6 +528,35 @@ class GravityBot(commands.Bot):
|
|||||||
if req.discord_message_id != 0:
|
if req.discord_message_id != 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Learn project mapping from pending approval
|
||||||
|
project = req.project_name or Config.PROJECT_NAME
|
||||||
|
if req.conversation_id and req.conversation_id != '__global__':
|
||||||
|
self.conv_to_project[req.conversation_id] = project
|
||||||
|
|
||||||
|
# ── Auto-approve: if project has auto enabled, approve immediately ──
|
||||||
|
if project in self.auto_approve_projects:
|
||||||
|
self._sent_approval_ids.add(req.request_id)
|
||||||
|
# Write auto-approve response for Extension
|
||||||
|
self.bridge.write_response(UserResponse(
|
||||||
|
request_id=req.request_id,
|
||||||
|
approved=True,
|
||||||
|
button_index=0, # first button (Allow Once / Run)
|
||||||
|
step_type=getattr(req, 'step_type', ''),
|
||||||
|
project_name=project,
|
||||||
|
))
|
||||||
|
# Show compact auto-approved embed in Discord
|
||||||
|
channel = await self._get_channel(project)
|
||||||
|
if channel:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🤖 자동 승인됨",
|
||||||
|
description=f"```\n{req.command[:500]}\n```",
|
||||||
|
color=discord.Color.green(),
|
||||||
|
)
|
||||||
|
embed.set_footer(text=f"auto-approve | {req.request_id[:12]}")
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
logger.info(f"Auto-approved: {req.request_id[:12]} project={project}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Defer short-command pendings (e.g. "Run") by 4 cycles (~12s)
|
# Defer short-command pendings (e.g. "Run") by 4 cycles (~12s)
|
||||||
# to give step_probe time to merge detailed command info
|
# to give step_probe time to merge detailed command info
|
||||||
# (step_probe MERGE happens ~10s after pending creation)
|
# (step_probe MERGE happens ~10s after pending creation)
|
||||||
@@ -540,11 +576,6 @@ class GravityBot(commands.Bot):
|
|||||||
# Clean up defer tracking
|
# Clean up defer tracking
|
||||||
self._deferred_ids.pop(req.request_id, None)
|
self._deferred_ids.pop(req.request_id, None)
|
||||||
|
|
||||||
# Learn project mapping from pending approval
|
|
||||||
project = req.project_name or Config.PROJECT_NAME
|
|
||||||
if req.conversation_id and req.conversation_id != '__global__':
|
|
||||||
self.conv_to_project[req.conversation_id] = project
|
|
||||||
|
|
||||||
channel = await self._get_channel(project)
|
channel = await self._get_channel(project)
|
||||||
if channel:
|
if channel:
|
||||||
self._sent_approval_ids.add(req.request_id)
|
self._sent_approval_ids.add(req.request_id)
|
||||||
@@ -732,17 +763,22 @@ class GravityBot(commands.Bot):
|
|||||||
await message.channel.send(embed=embed)
|
await message.channel.send(embed=embed)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Special command: !auto on/off
|
# Special command: !auto — toggle auto-approve
|
||||||
if text in ("!auto on", "!auto off"):
|
if text == "!auto":
|
||||||
self.bridge.write_command(project, text, project_name=project)
|
# Toggle per-project auto-approve
|
||||||
enabled = text == "!auto on"
|
if project in self.auto_approve_projects:
|
||||||
|
self.auto_approve_projects.discard(project)
|
||||||
|
enabled = False
|
||||||
|
else:
|
||||||
|
self.auto_approve_projects.add(project)
|
||||||
|
enabled = True
|
||||||
|
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
|
||||||
emoji = "🟢" if enabled else "🔴"
|
emoji = "🟢" if enabled else "🔴"
|
||||||
mode = "자동 승인" if enabled else "수동 승인"
|
mode = "자동 승인" if enabled else "수동 승인"
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"{emoji} {mode} 모드",
|
title=f"{emoji} {mode} 모드",
|
||||||
description=f"프로젝트: **{project}**\n"
|
description=f"프로젝트: **{project}**\n"
|
||||||
f"`chat.tools.autoApprove = {enabled}`\n"
|
f"모든 승인 요청이 {'자동으로 승인됩니다' if enabled else '수동 확인이 필요합니다'}",
|
||||||
f"`chat.agent.autoApprove = {enabled}`",
|
|
||||||
color=discord.Color.green() if enabled else discord.Color.red(),
|
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||||
)
|
)
|
||||||
await message.channel.send(embed=embed)
|
await message.channel.send(embed=embed)
|
||||||
|
|||||||
235
bridge.py
235
bridge.py
@@ -1,4 +1,4 @@
|
|||||||
"""Bridge protocol — file-based communication between Discord bot and Antigravity.
|
"""Bridge protocol — communication between Discord bot and Antigravity.
|
||||||
|
|
||||||
Bridge directory: ~/.gemini/antigravity/bridge/
|
Bridge directory: ~/.gemini/antigravity/bridge/
|
||||||
Structure:
|
Structure:
|
||||||
@@ -12,11 +12,16 @@ Protocol:
|
|||||||
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
|
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
|
||||||
3. User clicks button → Bot writes JSON to response/
|
3. User clicks button → Bot writes JSON to response/
|
||||||
4. VS Code Extension reads response/ → executes action
|
4. VS Code Extension reads response/ → executes action
|
||||||
|
|
||||||
|
Transport layer:
|
||||||
|
LocalTransport — file-based (default, single-PC)
|
||||||
|
RemoteTransport — HTTP-based (future: multi-PC collector mode)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -59,37 +64,158 @@ class UserResponse:
|
|||||||
project_name: str = "" # for multi-project: extension uses this when pending file is missing
|
project_name: str = "" # for multi-project: extension uses this when pending file is missing
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Transport Abstraction ───
|
||||||
|
|
||||||
|
class BridgeTransport(ABC):
|
||||||
|
"""Abstract transport for bridge I/O.
|
||||||
|
|
||||||
|
Implementations handle reading/writing JSON files for the bridge protocol,
|
||||||
|
regardless of whether the storage is local filesystem or remote HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_json_files(self, subdir: str) -> list[str]:
|
||||||
|
"""List JSON filenames in a subdirectory (e.g. 'pending', 'response')."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_json(self, subdir: str, filename: str) -> dict | None:
|
||||||
|
"""Read and parse a JSON file. Returns None if not found or corrupt."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def write_json(self, subdir: str, filename: str, data: dict) -> None:
|
||||||
|
"""Write data as JSON to a file in the given subdirectory."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_file(self, subdir: str, filename: str) -> bool:
|
||||||
|
"""Delete a file. Returns True if deleted, False if not found."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
"""Ensure all required subdirectories exist."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalTransport(BridgeTransport):
|
||||||
|
"""File-system based transport (default, single-PC mode).
|
||||||
|
|
||||||
|
Reads/writes directly to the bridge directory on local disk.
|
||||||
|
This is the existing behavior, extracted into a transport class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bridge_dir: Path):
|
||||||
|
self.bridge_dir = bridge_dir
|
||||||
|
|
||||||
|
def list_json_files(self, subdir: str) -> list[str]:
|
||||||
|
d = self.bridge_dir / subdir
|
||||||
|
if not d.exists():
|
||||||
|
return []
|
||||||
|
return [f.name for f in d.glob("*.json")]
|
||||||
|
|
||||||
|
def read_json(self, subdir: str, filename: str) -> dict | None:
|
||||||
|
fp = self.bridge_dir / subdir / filename
|
||||||
|
if not fp.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(fp.read_text(encoding="utf-8-sig"))
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning(f"LocalTransport: bad file {subdir}/{filename}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write_json(self, subdir: str, filename: str, data: dict) -> None:
|
||||||
|
d = self.bridge_dir / subdir
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
fp = d / filename
|
||||||
|
fp.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_file(self, subdir: str, filename: str) -> bool:
|
||||||
|
fp = self.bridge_dir / subdir / filename
|
||||||
|
if fp.exists():
|
||||||
|
try:
|
||||||
|
fp.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
for sub in ("pending", "response", "commands"):
|
||||||
|
(self.bridge_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteTransport(BridgeTransport):
|
||||||
|
"""HTTP-based transport for remote/multi-PC mode (skeleton).
|
||||||
|
|
||||||
|
Future implementation: polls a remote bridge HTTP server that
|
||||||
|
exposes the same pending/response/commands JSON files via API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
logger.info(f"RemoteTransport: initialized with {self.base_url}")
|
||||||
|
|
||||||
|
def list_json_files(self, subdir: str) -> list[str]:
|
||||||
|
raise NotImplementedError("RemoteTransport not yet implemented")
|
||||||
|
|
||||||
|
def read_json(self, subdir: str, filename: str) -> dict | None:
|
||||||
|
raise NotImplementedError("RemoteTransport not yet implemented")
|
||||||
|
|
||||||
|
def write_json(self, subdir: str, filename: str, data: dict) -> None:
|
||||||
|
raise NotImplementedError("RemoteTransport not yet implemented")
|
||||||
|
|
||||||
|
def delete_file(self, subdir: str, filename: str) -> bool:
|
||||||
|
raise NotImplementedError("RemoteTransport not yet implemented")
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
pass # Remote server manages its own directories
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Bridge Protocol (uses Transport) ───
|
||||||
|
|
||||||
class BridgeProtocol:
|
class BridgeProtocol:
|
||||||
"""Manages the file-based bridge protocol."""
|
"""Manages the bridge protocol via a pluggable transport."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, transport: BridgeTransport | None = None):
|
||||||
self.bridge_dir = Config.BRAIN_PATH.parent / "bridge"
|
if transport is None:
|
||||||
self.pending_dir = self.bridge_dir / "pending"
|
bridge_dir = Config.BRAIN_PATH.parent / "bridge"
|
||||||
self.response_dir = self.bridge_dir / "response"
|
transport = LocalTransport(bridge_dir)
|
||||||
self.commands_dir = self.bridge_dir / "commands"
|
self.transport = transport
|
||||||
|
|
||||||
# Create directories
|
# Legacy attributes for backward compatibility
|
||||||
for d in [self.pending_dir, self.response_dir, self.commands_dir]:
|
# (bot.py uses self.bridge.pending_dir etc. in some places)
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
if isinstance(transport, LocalTransport):
|
||||||
|
self.bridge_dir = transport.bridge_dir
|
||||||
|
self.pending_dir = transport.bridge_dir / "pending"
|
||||||
|
self.response_dir = transport.bridge_dir / "response"
|
||||||
|
self.commands_dir = transport.bridge_dir / "commands"
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
self.transport.ensure_dirs()
|
||||||
|
|
||||||
# Startup cleanup: purge stale pending files (> 5 min old)
|
# Startup cleanup: purge stale pending files (> 5 min old)
|
||||||
self._cleanup_stale_pending()
|
self._cleanup_stale_pending()
|
||||||
|
|
||||||
logger.info(f"Bridge protocol initialized: {self.bridge_dir}")
|
logger.info(f"Bridge protocol initialized: transport={type(transport).__name__}")
|
||||||
|
|
||||||
def _cleanup_stale_pending(self, max_age_seconds: int = 300):
|
def _cleanup_stale_pending(self, max_age_seconds: int = 300):
|
||||||
"""Remove pending files older than max_age_seconds on startup."""
|
"""Remove pending files older than max_age_seconds on startup."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cleaned = 0
|
cleaned = 0
|
||||||
for f in self.pending_dir.glob("*.json"):
|
for fname in self.transport.list_json_files("pending"):
|
||||||
try:
|
data = self.transport.read_json("pending", fname)
|
||||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
if data is None:
|
||||||
ts = data.get("timestamp", 0)
|
self.transport.delete_file("pending", fname)
|
||||||
if now - ts > max_age_seconds:
|
cleaned += 1
|
||||||
f.unlink()
|
continue
|
||||||
cleaned += 1
|
ts = data.get("timestamp", 0)
|
||||||
except (json.JSONDecodeError, OSError):
|
if now - ts > max_age_seconds:
|
||||||
f.unlink() # corrupt file, remove
|
self.transport.delete_file("pending", fname)
|
||||||
cleaned += 1
|
cleaned += 1
|
||||||
if cleaned:
|
if cleaned:
|
||||||
logger.info(f"Startup cleanup: removed {cleaned} stale pending files")
|
logger.info(f"Startup cleanup: removed {cleaned} stale pending files")
|
||||||
@@ -100,63 +226,53 @@ class BridgeProtocol:
|
|||||||
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||||
now = time.time()
|
now = time.time()
|
||||||
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
|
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
|
||||||
for f in self.pending_dir.glob("*.json"):
|
for fname in self.transport.list_json_files("pending"):
|
||||||
try:
|
data = self.transport.read_json("pending", fname)
|
||||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
if data is None:
|
||||||
ts = data.get("timestamp", 0)
|
continue
|
||||||
if now - ts > MAX_AGE:
|
ts = data.get("timestamp", 0)
|
||||||
# Too old — mark expired and skip
|
if now - ts > MAX_AGE:
|
||||||
data["status"] = "expired"
|
# Too old — mark expired and skip
|
||||||
f.write_text(
|
data["status"] = "expired"
|
||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
self.transport.write_json("pending", fname, data)
|
||||||
encoding="utf-8",
|
continue
|
||||||
)
|
if data.get("status") == "pending":
|
||||||
continue
|
# Filter to known fields only
|
||||||
if data.get("status") == "pending":
|
filtered = {k: v for k, v in data.items() if k in fields}
|
||||||
# Filter to known fields only
|
try:
|
||||||
filtered = {k: v for k, v in data.items() if k in fields}
|
|
||||||
requests.append(ApprovalRequest(**filtered))
|
requests.append(ApprovalRequest(**filtered))
|
||||||
except (json.JSONDecodeError, TypeError, OSError) as e:
|
except TypeError as e:
|
||||||
logger.warning(f"Bad pending request {f.name}: {e}")
|
logger.warning(f"Bad pending request {fname}: {e}")
|
||||||
return requests
|
return requests
|
||||||
|
|
||||||
def read_pending_request(self, request_id: str) -> ApprovalRequest | None:
|
def read_pending_request(self, request_id: str) -> ApprovalRequest | None:
|
||||||
"""Re-read a specific pending request (to get merged data)."""
|
"""Re-read a specific pending request (to get merged data)."""
|
||||||
f = self.pending_dir / f"{request_id}.json"
|
fname = f"{request_id}.json"
|
||||||
if not f.exists():
|
data = self.transport.read_json("pending", fname)
|
||||||
|
if data is None:
|
||||||
return None
|
return None
|
||||||
|
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
|
||||||
|
filtered = {k: v for k, v in data.items() if k in fields}
|
||||||
try:
|
try:
|
||||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
|
||||||
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
|
|
||||||
filtered = {k: v for k, v in data.items() if k in fields}
|
|
||||||
return ApprovalRequest(**filtered)
|
return ApprovalRequest(**filtered)
|
||||||
except (json.JSONDecodeError, TypeError, OSError):
|
except TypeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def write_response(self, response: UserResponse):
|
def write_response(self, response: UserResponse):
|
||||||
"""Write a user response to the response directory."""
|
"""Write a user response to the response directory."""
|
||||||
response.timestamp = time.time()
|
response.timestamp = time.time()
|
||||||
filename = f"{response.request_id}.json"
|
fname = f"{response.request_id}.json"
|
||||||
filepath = self.response_dir / filename
|
|
||||||
|
|
||||||
filepath.write_text(
|
self.transport.write_json("response", fname, asdict(response))
|
||||||
json.dumps(asdict(response), ensure_ascii=False, indent=2),
|
logger.info(f"Response written: {fname} (approved={response.approved})")
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
logger.info(f"Response written: {filename} (approved={response.approved})")
|
|
||||||
|
|
||||||
# Delete pending file after processing (prevents re-processing and accumulation)
|
# Delete pending file after processing (prevents re-processing and accumulation)
|
||||||
pending_file = self.pending_dir / filename
|
self.transport.delete_file("pending", fname)
|
||||||
if pending_file.exists():
|
|
||||||
try:
|
|
||||||
pending_file.unlink()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||||
"""Write a user text command for Antigravity to consume."""
|
"""Write a user text command for Antigravity to consume."""
|
||||||
cmd_id = f"{int(time.time() * 1000)}"
|
cmd_id = f"{int(time.time() * 1000)}"
|
||||||
filepath = self.commands_dir / f"{cmd_id}.json"
|
fname = f"{cmd_id}.json"
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"id": cmd_id,
|
"id": cmd_id,
|
||||||
@@ -167,9 +283,6 @@ class BridgeProtocol:
|
|||||||
"consumed": False,
|
"consumed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
filepath.write_text(
|
self.transport.write_json("commands", fname, data)
|
||||||
json.dumps(data, ensure_ascii=False, indent=2),
|
|
||||||
encoding="utf-8"
|
|
||||||
)
|
|
||||||
logger.info(f"Command written: {cmd_id} → project={project_name}")
|
logger.info(f"Command written: {cmd_id} → project={project_name}")
|
||||||
return cmd_id
|
return cmd_id
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class Config:
|
|||||||
CHANNEL_PREFIX: str = "AG"
|
CHANNEL_PREFIX: str = "AG"
|
||||||
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
|
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
|
||||||
|
|
||||||
|
# Bot mode: 'local' (file-based bridge) or 'remote' (HTTP polling — future)
|
||||||
|
BOT_MODE: str = os.getenv("BOT_MODE", "local")
|
||||||
|
REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls) -> list[str]:
|
def validate(cls) -> list[str]:
|
||||||
"""Return list of configuration errors."""
|
"""Return list of configuration errors."""
|
||||||
|
|||||||
@@ -5,3 +5,6 @@
|
|||||||
| 001 | 00:00~00:20 | Discord 릴레이 미작동 진단 — config.py BRAIN_PATH 빈문자열 버그 수정 | `pending` | ✅ |
|
| 001 | 00:00~00:20 | Discord 릴레이 미작동 진단 — config.py BRAIN_PATH 빈문자열 버그 수정 | `pending` | ✅ |
|
||||||
| 002 | 00:20~01:05 | 크로스 프로젝트 pending DEDUP MERGE 버그 진단 및 수정 (project_name 가드 3곳) | `pending` | ✅ |
|
| 002 | 00:20~01:05 | 크로스 프로젝트 pending DEDUP MERGE 버그 진단 및 수정 (project_name 가드 3곳) | `pending` | ✅ |
|
||||||
| 003 | 09:25~09:33 | Auto-approve 기능 감사 (미구현 확인) + Vikunja 태스크 #304, #305 등록 | `pending` | ✅ |
|
| 003 | 09:25~09:33 | Auto-approve 기능 감사 (미구현 확인) + Vikunja 태스크 #304, #305 등록 | `pending` | ✅ |
|
||||||
|
| 004 | 10:00~10:35 | P1: `!auto` 토글 자동 승인 구현 (bot.py + extension.ts) | `pending` | ✅ |
|
||||||
|
| 005 | 10:35~10:45 | P2: BridgeTransport 추상화 (bridge.py 리팩토링 + config/main 모드 설정) | `pending` | ✅ |
|
||||||
|
| 006 | 10:43~10:55 | 사용 가이드 작성 (docs/usage-guide.md) + tech-stack.md Python 경로 기록 | `pending` | ✅ |
|
||||||
|
|||||||
127
docs/usage-guide.md
Normal file
127
docs/usage-guide.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Gravity Control 사용 가이드
|
||||||
|
|
||||||
|
## 시작하기
|
||||||
|
|
||||||
|
### 1. 봇 실행
|
||||||
|
|
||||||
|
```batch
|
||||||
|
start_bot.bat
|
||||||
|
```
|
||||||
|
또는:
|
||||||
|
```powershell
|
||||||
|
C:\ProgramData\miniforge3\envs\gravity_control\python.exe main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Extension 설치
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd extension
|
||||||
|
cmd /c npm run compile
|
||||||
|
cmd /c npx vsce package
|
||||||
|
# 생성된 .vsix 파일을 VS Code에서 설치
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discord 명령어
|
||||||
|
|
||||||
|
### 채팅 명령어 (메시지 입력)
|
||||||
|
|
||||||
|
| 명령어 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `!auto` | 자동 승인 토글 (on/off 반복) |
|
||||||
|
| `!stop` | AG 에이전트 중단 |
|
||||||
|
| 그 외 텍스트 | AG에 직접 메시지 전달 |
|
||||||
|
|
||||||
|
### 슬래시 명령어
|
||||||
|
|
||||||
|
| 명령어 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `/auto` | 자동 승인 토글 (on/off 반복) |
|
||||||
|
| `/stop` | AG 에이전트 중단 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자동 승인 (`!auto`)
|
||||||
|
|
||||||
|
Discord에서 `!auto` 를 입력할 때마다 on↔off 토글됩니다.
|
||||||
|
|
||||||
|
### 동작
|
||||||
|
|
||||||
|
- **OFF (기본)**: 승인 요청마다 Discord에 ✅/❌ 버튼 표시 → 클릭하여 수동 승인
|
||||||
|
- **ON**: 승인 요청 시 자동으로 승인 → Discord에 `🤖 자동 승인됨` 표시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: !auto
|
||||||
|
봇: 🟢 자동 승인 모드
|
||||||
|
프로젝트: gravity_control
|
||||||
|
모든 승인 요청이 자동으로 승인됩니다
|
||||||
|
|
||||||
|
사용자: !auto
|
||||||
|
봇: 🔴 수동 승인 모드
|
||||||
|
프로젝트: gravity_control
|
||||||
|
모든 승인 요청이 수동 확인이 필요합니다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자동 승인 시 Discord 표시
|
||||||
|
|
||||||
|
```
|
||||||
|
🤖 자동 승인됨
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ run_command: npm run build │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
auto-approve | 1741678...
|
||||||
|
```
|
||||||
|
|
||||||
|
> **주의**: 봇 재시작 시 auto-approve 상태는 초기화됩니다 (기본 OFF).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[AG IDE] ← Extension → bridge/ ← Bot → Discord
|
||||||
|
│ │
|
||||||
|
└── step_probe └── pending 스캔
|
||||||
|
(WAITING 감지) (자동 승인 처리)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bridge 프로토콜
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.gemini/antigravity/bridge/
|
||||||
|
├── pending/ Extension → Bot (승인 요청)
|
||||||
|
├── response/ Bot → Extension (승인 결과)
|
||||||
|
├── commands/ Bot → Extension (사용자 명령)
|
||||||
|
└── register/ Extension → Bot (세션 매핑)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot Mode
|
||||||
|
|
||||||
|
| 모드 | 설정 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `local` (기본) | `BOT_MODE=local` | 로컬 파일시스템 bridge 사용 |
|
||||||
|
| `remote` (미래) | `BOT_MODE=remote` | HTTP로 원격 bridge 폴링 (미구현) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 설정 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISCORD_TOKEN=xxx # Discord 봇 토큰 (필수)
|
||||||
|
DISCORD_GUILD_ID=xxx # Discord 서버 ID (필수)
|
||||||
|
BRAIN_PATH= # AG 브레인 경로 (기본: ~/.gemini/antigravity/brain)
|
||||||
|
BOT_MODE=local # 봇 모드 (local/remote)
|
||||||
|
REMOTE_BRIDGE_URL= # 원격 브릿지 URL (remote 모드 전용)
|
||||||
|
DEBOUNCE_SECONDS=2 # 이벤트 디바운스 (초)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
| 증상 | 해결 |
|
||||||
|
|------|------|
|
||||||
|
| `!auto` 했는데 자동 승인 안 됨 | Extension VSIX 재빌드 + 재설치 필요 |
|
||||||
|
| 봇 재시작 후 auto가 꺼져있음 | 정상 — `!auto`로 다시 켜기 |
|
||||||
|
| Python 못 찾음 | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe` 사용 |
|
||||||
@@ -42,6 +42,7 @@ let bridgePath: string;
|
|||||||
let projectName: string;
|
let projectName: string;
|
||||||
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
|
let workspaceUri: string = ''; // 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: fs.FSWatcher | null = null;
|
let watcher: fs.FSWatcher | null = null;
|
||||||
let commandsWatcher: fs.FSWatcher | null = null;
|
let commandsWatcher: fs.FSWatcher | null = null;
|
||||||
@@ -155,10 +156,17 @@ function processCommandFile(filePath: string) {
|
|||||||
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
||||||
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
|
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
|
||||||
() => { });
|
() => { });
|
||||||
} 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)
|
||||||
recentDiscordSentTexts.set(text.trim(), Date.now());
|
recentDiscordSentTexts.set(text.trim(), Date.now());
|
||||||
@@ -1891,14 +1899,20 @@ function setupMonitor() {
|
|||||||
lastPendingStepIndex = actualIndex;
|
lastPendingStepIndex = actualIndex;
|
||||||
lastPendingTime = Date.now();
|
lastPendingTime = Date.now();
|
||||||
sawRunningAfterPending = false;
|
sawRunningAfterPending = false;
|
||||||
writePendingApproval({
|
// Auto-approve: skip Discord, approve directly
|
||||||
conversation_id: activeSessionId,
|
if (autoApproveEnabled) {
|
||||||
command,
|
logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`);
|
||||||
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
|
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);
|
||||||
step_type: ['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,
|
} else {
|
||||||
step_index: actualIndex,
|
writePendingApproval({
|
||||||
source: 'step_probe_offset',
|
conversation_id: activeSessionId,
|
||||||
});
|
command,
|
||||||
|
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
|
||||||
|
step_type: ['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,
|
||||||
|
step_index: actualIndex,
|
||||||
|
source: 'step_probe_offset',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1946,14 +1960,20 @@ function setupMonitor() {
|
|||||||
lastPendingStepIndex = si;
|
lastPendingStepIndex = si;
|
||||||
lastPendingTime = Date.now();
|
lastPendingTime = Date.now();
|
||||||
sawRunningAfterPending = false;
|
sawRunningAfterPending = false;
|
||||||
writePendingApproval({
|
// Auto-approve: skip Discord, approve directly
|
||||||
conversation_id: activeSessionId,
|
if (autoApproveEnabled) {
|
||||||
command,
|
logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`);
|
||||||
description,
|
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);
|
||||||
step_type: ['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,
|
} else {
|
||||||
step_index: si,
|
writePendingApproval({
|
||||||
source: 'step_probe',
|
conversation_id: activeSessionId,
|
||||||
});
|
command,
|
||||||
|
description,
|
||||||
|
step_type: ['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,
|
||||||
|
step_index: si,
|
||||||
|
source: 'step_probe',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
16
main.py
16
main.py
@@ -42,6 +42,7 @@ async def main():
|
|||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
logger.info(f"Brain path: {Config.BRAIN_PATH}")
|
logger.info(f"Brain path: {Config.BRAIN_PATH}")
|
||||||
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
|
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
|
||||||
|
logger.info(f"Bot mode: {Config.BOT_MODE}")
|
||||||
|
|
||||||
# Shared event queue
|
# Shared event queue
|
||||||
event_queue = asyncio.Queue()
|
event_queue = asyncio.Queue()
|
||||||
@@ -49,10 +50,25 @@ async def main():
|
|||||||
# Get the running loop
|
# Get the running loop
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Create transport based on BOT_MODE
|
||||||
|
transport = None # None → LocalTransport (default)
|
||||||
|
if Config.BOT_MODE == "remote":
|
||||||
|
from bridge import RemoteTransport
|
||||||
|
if not Config.REMOTE_BRIDGE_URL:
|
||||||
|
logger.error("REMOTE_BRIDGE_URL is required for remote mode")
|
||||||
|
sys.exit(1)
|
||||||
|
transport = RemoteTransport(Config.REMOTE_BRIDGE_URL)
|
||||||
|
logger.info(f"Remote transport: {Config.REMOTE_BRIDGE_URL}")
|
||||||
|
|
||||||
# Create components
|
# Create components
|
||||||
watcher = BrainWatcher(event_queue, loop)
|
watcher = BrainWatcher(event_queue, loop)
|
||||||
bot = GravityBot(event_queue)
|
bot = GravityBot(event_queue)
|
||||||
|
|
||||||
|
# Inject transport if specified (otherwise bot uses default LocalTransport)
|
||||||
|
if transport is not None:
|
||||||
|
from bridge import BridgeProtocol
|
||||||
|
bot.bridge = BridgeProtocol(transport)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start watcher (runs in a separate thread via watchdog)
|
# Start watcher (runs in a separate thread via watchdog)
|
||||||
watcher.start()
|
watcher.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user