zioinfo-mail/itsm/core/vibe_bridge.py
DESKTOP-TKLFCPR\ython e228faabf5 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

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