- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
406 lines
15 KiB
Python
406 lines
15 KiB
Python
"""
|
|
core/vibe_bridge.py — Claude CLI SDK 비동기 브리지.
|
|
|
|
subprocess 방식 대신 Python SDK를 직접 연동하여
|
|
Claude CLI 세션 수명주기를 관리한다.
|
|
|
|
보안:
|
|
- CLAUDE_CLI_PATH 환경변수로 경로 지정 (기본: /usr/local/bin/claude)
|
|
- 워크스페이스는 CLAUDE_WORKSPACE_ROOT 이하로만 허용
|
|
- 세션 메타는 tb_vibe_session.claude_session_id 에 영속화
|
|
- 외부 API 호출 없음 — 로컬 Claude CLI 전용
|
|
|
|
환경변수:
|
|
CLAUDE_CLI_PATH 클로드 CLI 실행 파일 경로 (default: /usr/local/bin/claude)
|
|
CLAUDE_WORKSPACE_ROOT 코딩 워크스페이스 루트 (default: /opt/guardia/workspaces)
|
|
CLAUDE_SESSION_TIMEOUT 세션 비활성 타임아웃 초 (default: 3600)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import AsyncGenerator, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 환경변수 ──────────────────────────────────────────────────────────────────
|
|
_CLI_PATH = os.getenv("CLAUDE_CLI_PATH", "/usr/local/bin/claude")
|
|
_WORKSPACE_ROOT = Path(os.getenv("CLAUDE_WORKSPACE_ROOT", "/opt/guardia/workspaces"))
|
|
_SESSION_TIMEOUT = int(os.getenv("CLAUDE_SESSION_TIMEOUT", "3600"))
|
|
|
|
|
|
# ── 데이터 클래스 ────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class SessionState:
|
|
session_id: str
|
|
sr_id: Optional[str]
|
|
project_path: Path
|
|
active: bool = True
|
|
last_used: datetime = field(default_factory=datetime.utcnow)
|
|
tokens_used: int = 0
|
|
last_response: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SessionStatus:
|
|
active: bool
|
|
last_response: str
|
|
tokens_used: int
|
|
last_used: Optional[datetime]
|
|
|
|
|
|
# ── VibeBridge ───────────────────────────────────────────────────────────────
|
|
|
|
class VibeBridge:
|
|
"""
|
|
Claude CLI SDK 비동기 브리지.
|
|
|
|
사용 예시:
|
|
bridge = VibeBridge()
|
|
session_id = await bridge.start_session("SR-2026-0001", "/opt/src/proj")
|
|
response = await bridge.send_message(session_id, "이 버그를 수정해주세요")
|
|
await bridge.close_session(session_id)
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, SessionState] = {}
|
|
|
|
# ── 세션 시작 ─────────────────────────────────────────────────────────────
|
|
|
|
async def start_session(self, sr_id: str, project_path: str) -> str:
|
|
"""
|
|
새 Claude CLI 세션을 시작하고 session_id를 반환한다.
|
|
|
|
Args:
|
|
sr_id: 연결된 SR 번호 (예: SR-20260526-000001)
|
|
project_path: 작업 대상 디렉터리 (CLAUDE_WORKSPACE_ROOT 이하여야 함)
|
|
|
|
Returns:
|
|
session_id: Claude CLI가 반환한 세션 ID 문자열
|
|
|
|
Raises:
|
|
ValueError: 경로 순회 공격 감지 시
|
|
RuntimeError: Claude CLI 실행 실패 시
|
|
"""
|
|
safe_path = self._validate_path(project_path)
|
|
|
|
# Claude CLI SDK 초기화 명령
|
|
# --output-format json: 구조화 응답
|
|
# --session-id auto: CLI가 새 세션 ID 생성
|
|
cmd = [
|
|
_CLI_PATH,
|
|
"--print", # 비대화형 모드
|
|
"--output-format", "json",
|
|
"--cwd", str(safe_path),
|
|
"세션을 시작합니다. 프로젝트 구조를 파악해주세요.",
|
|
]
|
|
|
|
logger.info("Claude 세션 시작: sr_id=%s path=%s", sr_id, safe_path)
|
|
|
|
output, session_id = await self._run_claude(cmd)
|
|
|
|
state = SessionState(
|
|
session_id=session_id,
|
|
sr_id=sr_id,
|
|
project_path=safe_path,
|
|
last_response=output,
|
|
)
|
|
self._sessions[session_id] = state
|
|
logger.info("Claude 세션 등록: session_id=%s", session_id)
|
|
return session_id
|
|
|
|
# ── 메시지 전송 ───────────────────────────────────────────────────────────
|
|
|
|
async def send_message(self, session_id: str, message: str) -> str:
|
|
"""
|
|
기존 세션에 메시지를 전송하고 응답을 반환한다.
|
|
|
|
Args:
|
|
session_id: start_session() 이 반환한 세션 ID
|
|
message: 사용자(또는 ITSM)가 Claude에게 전달할 지시문
|
|
|
|
Returns:
|
|
Claude의 텍스트 응답
|
|
|
|
Raises:
|
|
KeyError: 세션이 존재하지 않을 때
|
|
RuntimeError: CLI 실행 실패 시
|
|
"""
|
|
state = self._get_state(session_id)
|
|
|
|
cmd = [
|
|
_CLI_PATH,
|
|
"--print",
|
|
"--output-format", "json",
|
|
"--resume", session_id,
|
|
"--cwd", str(state.project_path),
|
|
message,
|
|
]
|
|
|
|
logger.debug("Claude 메시지 전송: session_id=%s msg_len=%d", session_id, len(message))
|
|
response, _ = await self._run_claude(cmd)
|
|
|
|
state.last_response = response
|
|
state.last_used = datetime.utcnow()
|
|
return response
|
|
|
|
# ── 세션 재개 ─────────────────────────────────────────────────────────────
|
|
|
|
async def resume_session(self, session_id: str, message: str) -> str:
|
|
"""
|
|
비활성 세션을 재개하거나 메모리 내 상태가 없는 세션을 복원하여 메시지 전송.
|
|
|
|
Args:
|
|
session_id: 복원할 세션 ID (tb_vibe_session.claude_session_id)
|
|
message: 전달할 메시지
|
|
|
|
Returns:
|
|
Claude의 텍스트 응답
|
|
"""
|
|
# 메모리에 없으면 workspace_root / session_id 경로로 가정
|
|
if session_id not in self._sessions:
|
|
workspace = _WORKSPACE_ROOT / session_id
|
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
state = SessionState(
|
|
session_id=session_id,
|
|
sr_id=None,
|
|
project_path=workspace,
|
|
active=True,
|
|
)
|
|
self._sessions[session_id] = state
|
|
|
|
return await self.send_message(session_id, message)
|
|
|
|
# ── 세션 상태 조회 ────────────────────────────────────────────────────────
|
|
|
|
async def get_session_status(self, session_id: str) -> SessionStatus:
|
|
"""
|
|
세션의 현재 상태를 반환한다.
|
|
|
|
Returns:
|
|
SessionStatus(active, last_response, tokens_used, last_used)
|
|
"""
|
|
state = self._sessions.get(session_id)
|
|
if state is None:
|
|
return SessionStatus(active=False, last_response="", tokens_used=0, last_used=None)
|
|
|
|
# 타임아웃 확인
|
|
idle = (datetime.utcnow() - state.last_used).total_seconds()
|
|
if idle > _SESSION_TIMEOUT:
|
|
state.active = False
|
|
logger.info("세션 타임아웃: session_id=%s idle=%.0fs", session_id, idle)
|
|
|
|
return SessionStatus(
|
|
active=state.active,
|
|
last_response=state.last_response,
|
|
tokens_used=state.tokens_used,
|
|
last_used=state.last_used,
|
|
)
|
|
|
|
# ── 세션 종료 ─────────────────────────────────────────────────────────────
|
|
|
|
async def close_session(self, session_id: str) -> bool:
|
|
"""
|
|
세션을 종료하고 메모리에서 제거한다.
|
|
|
|
Returns:
|
|
True: 성공적으로 종료됨
|
|
False: 세션이 존재하지 않음
|
|
"""
|
|
state = self._sessions.pop(session_id, None)
|
|
if state is None:
|
|
logger.warning("종료 요청: 세션 없음 session_id=%s", session_id)
|
|
return False
|
|
|
|
state.active = False
|
|
logger.info("Claude 세션 종료: session_id=%s tokens=%d", session_id, state.tokens_used)
|
|
return True
|
|
|
|
# ── 스트리밍 응답 ────────────────────────────────────────────────────────
|
|
|
|
async def stream_message(
|
|
self, session_id: str, message: str
|
|
) -> AsyncGenerator[str, None]:
|
|
"""
|
|
메시지를 전송하고 응답을 청크 단위로 스트리밍한다.
|
|
SSE 엔드포인트와 연동하여 실시간 피드백에 사용한다.
|
|
|
|
Yields:
|
|
str: 응답 텍스트 청크
|
|
"""
|
|
state = self._get_state(session_id)
|
|
|
|
cmd = [
|
|
_CLI_PATH,
|
|
"--print",
|
|
"--output-format", "stream-json", # 스트리밍 JSON 모드
|
|
"--resume", session_id,
|
|
"--cwd", str(state.project_path),
|
|
message,
|
|
]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
assert proc.stdout is not None
|
|
full_response: list[str] = []
|
|
|
|
async for line_bytes in proc.stdout:
|
|
line = line_bytes.decode("utf-8", errors="replace").strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
chunk = obj.get("delta", {}).get("text", "") or obj.get("text", "")
|
|
if chunk:
|
|
full_response.append(chunk)
|
|
yield chunk
|
|
# 토큰 집계
|
|
usage = obj.get("usage", {})
|
|
if usage:
|
|
state.tokens_used += usage.get("output_tokens", 0)
|
|
except json.JSONDecodeError:
|
|
yield line
|
|
|
|
state.last_response = "".join(full_response)
|
|
state.last_used = datetime.utcnow()
|
|
await proc.wait()
|
|
|
|
# ── 내부 유틸 ─────────────────────────────────────────────────────────────
|
|
|
|
def _validate_path(self, path_str: str) -> Path:
|
|
"""경로 순회(path traversal) 공격 방지."""
|
|
_WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
|
|
p = Path(path_str).resolve()
|
|
try:
|
|
p.relative_to(_WORKSPACE_ROOT.resolve())
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"허용되지 않는 경로: {path_str} "
|
|
f"(CLAUDE_WORKSPACE_ROOT={_WORKSPACE_ROOT} 이하만 허용)"
|
|
)
|
|
return p
|
|
|
|
def _get_state(self, session_id: str) -> SessionState:
|
|
"""세션 상태 조회 — 없으면 KeyError."""
|
|
state = self._sessions.get(session_id)
|
|
if state is None:
|
|
raise KeyError(f"세션을 찾을 수 없습니다: {session_id}")
|
|
return state
|
|
|
|
async def _run_claude(self, cmd: list[str]) -> tuple[str, str]:
|
|
"""
|
|
Claude CLI 프로세스를 실행하고 (응답 텍스트, 세션 ID) 를 반환한다.
|
|
|
|
Returns:
|
|
(response_text, session_id)
|
|
Raises:
|
|
RuntimeError: 프로세스 실행 실패 또는 비정상 종료 시
|
|
"""
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except FileNotFoundError:
|
|
raise RuntimeError(
|
|
f"Claude CLI 실행 파일을 찾을 수 없습니다: {_CLI_PATH}. "
|
|
"CLAUDE_CLI_PATH 환경변수를 확인하세요."
|
|
)
|
|
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=float(_SESSION_TIMEOUT),
|
|
)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
raise RuntimeError("Claude CLI 응답 타임아웃")
|
|
|
|
if proc.returncode != 0:
|
|
err = stderr.decode("utf-8", errors="replace")[:500]
|
|
raise RuntimeError(f"Claude CLI 실행 실패 (rc={proc.returncode}): {err}")
|
|
|
|
raw = stdout.decode("utf-8", errors="replace").strip()
|
|
|
|
# JSON 파싱 시도
|
|
session_id = _extract_session_id(raw)
|
|
response_text = _extract_response_text(raw)
|
|
|
|
return response_text, session_id
|
|
|
|
|
|
# ── 파싱 헬퍼 ────────────────────────────────────────────────────────────────
|
|
|
|
def _extract_session_id(raw: str) -> str:
|
|
"""CLI 출력에서 session_id 추출."""
|
|
for line in raw.splitlines():
|
|
try:
|
|
obj = json.loads(line)
|
|
sid = (
|
|
obj.get("session_id")
|
|
or obj.get("sessionId")
|
|
or (obj.get("session", {}) or {}).get("id")
|
|
)
|
|
if sid:
|
|
return str(sid)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
# UUID 패턴으로 폴백
|
|
m = re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", raw, re.I)
|
|
if m:
|
|
return m.group(0)
|
|
|
|
# 최후 수단: 타임스탬프 기반 ID 생성
|
|
import uuid
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
def _extract_response_text(raw: str) -> str:
|
|
"""CLI JSON 출력에서 텍스트 응답 추출."""
|
|
texts: list[str] = []
|
|
for line in raw.splitlines():
|
|
try:
|
|
obj = json.loads(line)
|
|
text = (
|
|
obj.get("result")
|
|
or obj.get("text")
|
|
or obj.get("content")
|
|
or (obj.get("message", {}) or {}).get("content")
|
|
)
|
|
if isinstance(text, str) and text:
|
|
texts.append(text)
|
|
elif isinstance(text, list):
|
|
for block in text:
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
texts.append(block.get("text", ""))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
return "\n".join(texts) if texts else raw[:2000]
|
|
|
|
|
|
# ── 싱글턴 ──────────────────────────────────────────────────────────────────
|
|
|
|
_bridge_instance: Optional[VibeBridge] = None
|
|
|
|
|
|
def get_vibe_bridge() -> VibeBridge:
|
|
"""싱글턴 VibeBridge 인스턴스를 반환한다."""
|
|
global _bridge_instance
|
|
if _bridge_instance is None:
|
|
_bridge_instance = VibeBridge()
|
|
return _bridge_instance
|