diff --git a/nanoclaude/services/backend_registry.py b/nanoclaude/services/backend_registry.py index 7a2483e..ed3a36e 100644 --- a/nanoclaude/services/backend_registry.py +++ b/nanoclaude/services/backend_registry.py @@ -28,6 +28,11 @@ CLASSIFIER_PROMPT = """\ - email.read(uid) — 메일 본문 조회 - document.search(query) — 문서 검색 - document.read(doc_id) — 문서 조회 +- infra.status(host) — 서버 Docker 컨테이너 상태. host: gpu (기본), nas-company +- infra.health(service) — 서비스 헬스체크. service 생략 시 전체 체크. 개별: document-server, mlx, ollama-gpu +- infra.disk(host) — 디스크 사용률. host 생략 시 gpu+macmini 전체 +- infra.network() — Tailscale 네트워크 연결 상태 +- infra.models(host) — 모델 목록. host: gpu (Ollama), mlx (맥미니 MLX) 메일 전송은 불가능하다. 메일 보내달라는 요청은 거부하라. @@ -47,6 +52,11 @@ JSON 형식: - "내일 3시 회의" → tools: calendar.create_draft(...) - "최근 메일" → tools: email.search("", 7) - "문서 찾아줘" → tools: document.search(...) +- "GPU 서버 상태" → tools: infra.status("gpu") +- "서비스 살아있어?" → tools: infra.health() +- "디스크 용량" → tools: infra.disk() +- "네트워크 상태" → tools: infra.network() +- "모델 뭐 있어?" → tools: infra.models("gpu") - "안녕" → direct - "양자역학 설명해줘" → route diff --git a/nanoclaude/tools/infra_tool.py b/nanoclaude/tools/infra_tool.py new file mode 100644 index 0000000..33308a6 --- /dev/null +++ b/nanoclaude/tools/infra_tool.py @@ -0,0 +1,112 @@ +"""Infra tool — NanoClaude wrapper over infra.core/ functions. + +Converts infra.core results to NanoClaude tool return format: +{"ok": bool, "tool": "infra", "operation": str, "data": list, "summary": str, "error": str} +""" + +from __future__ import annotations + +import asyncio +import logging + +from infra.core.docker import docker_status +from infra.core.health import service_health, VALID_SERVICES +from infra.core.system import disk_usage +from infra.core.network import tailscale_status +from infra.core.models import ollama_models, mlx_models + +logger = logging.getLogger(__name__) + + +async def status(host: str = "gpu") -> dict: + """Docker container status overview.""" + result = await docker_status(host) + if not result.ok: + return {"ok": False, "tool": "infra", "operation": "status", + "data": [], "summary": "", "error": result.error or "확인 실패"} + + data = [{"name": c.name, "status": c.status, "uptime": c.uptime} + for c in result.containers] + return {"ok": True, "tool": "infra", "operation": "status", + "data": data, "summary": result.summary, "error": ""} + + +async def health(service: str = "") -> dict: + """Service health check. If no service specified, check all critical ones.""" + if service: + services = [service] + else: + services = ["document-server", "mlx", "ollama-gpu"] + + results = [] + all_ok = True + for svc in services: + r = await service_health(svc) + results.append({ + "service": r.service, "status": r.status, "ok": r.ok, + "details": r.details, + }) + if not r.ok: + all_ok = False + + summary_parts = [] + for r in results: + icon = "정상" if r["ok"] else "이상" + summary_parts.append(f"{r['service']}: {icon}") + + return {"ok": all_ok, "tool": "infra", "operation": "health", + "data": results, "summary": ", ".join(summary_parts), "error": ""} + + +async def disk(host: str = "") -> dict: + """Disk usage. If no host, check gpu + macmini.""" + hosts = [host] if host else ["gpu", "macmini"] + all_data = [] + warnings = [] + + for h in hosts: + result = await disk_usage(h) + if not result.ok: + warnings.append(f"{h}: {result.error}") + continue + for fs in result.filesystems: + all_data.append({"host": h, "mount": fs.mount, + "used_pct": fs.used_pct, "used": fs.used, "total": fs.total}) + warnings.extend(result.warnings) + + summary = ", ".join(f"{d['host']}:{d['mount']} {d['used_pct']}%" for d in all_data[:5]) + return {"ok": len(warnings) == 0, "tool": "infra", "operation": "disk", + "data": all_data, "summary": summary, + "error": "; ".join(warnings) if warnings else ""} + + +async def network() -> dict: + """Tailscale network status.""" + result = await tailscale_status() + if not result.ok: + return {"ok": False, "tool": "infra", "operation": "network", + "data": [], "summary": "", "error": result.error or "확인 실패"} + + data = [{"hostname": p.hostname, "ip": p.ip, "status": p.status, "os": p.os} + for p in result.peers] + online = sum(1 for p in result.peers if p.status != "offline") + summary = f"{online}/{len(result.peers)} 온라인" + return {"ok": True, "tool": "infra", "operation": "network", + "data": data, "summary": summary, "error": ""} + + +async def models(host: str = "gpu") -> dict: + """Model inventory.""" + if host == "mlx" or host == "macmini": + result = await mlx_models() + else: + result = await ollama_models(host) + + if not result.ok: + return {"ok": False, "tool": "infra", "operation": "models", + "data": [], "summary": "", "error": result.error or "확인 실패"} + + data = [{"id": m.id, "size": m.size} for m in result.models] + summary = f"{result.source} on {result.host}: {len(result.models)}개 모델" + return {"ok": True, "tool": "infra", "operation": "models", + "data": data, "summary": summary, "error": ""} diff --git a/nanoclaude/tools/registry.py b/nanoclaude/tools/registry.py index f771af6..5a0a7b6 100644 --- a/nanoclaude/tools/registry.py +++ b/nanoclaude/tools/registry.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from tools import calendar_tool, document_tool, email_tool +from tools import calendar_tool, document_tool, email_tool, infra_tool logger = logging.getLogger(__name__) @@ -13,6 +13,7 @@ ERROR_MESSAGES = { "calendar": "⚠️ 캘린더 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.", "email": "⚠️ 메일 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.", "document": "⚠️ 문서 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해주세요.", + "infra": "⚠️ 인프라 서비스를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.", } # 허용된 operations @@ -20,6 +21,7 @@ ALLOWED_OPS = { "calendar": {"today", "search", "create_draft", "create_confirmed"}, "email": {"search", "read"}, "document": {"search", "read"}, + "infra": {"status", "health", "disk", "network", "models"}, } # payload hard limit @@ -43,6 +45,8 @@ async def execute_tool(tool_name: str, operation: str, params: dict) -> dict: result = await _exec_email(operation, params) elif tool_name == "document": result = await _exec_document(operation, params) + elif tool_name == "infra": + result = await _exec_infra(operation, params) else: result = _error(tool_name, operation, "미구현") @@ -98,5 +102,19 @@ async def _exec_document(operation: str, params: dict) -> dict: return _error("document", operation, "미구현") +async def _exec_infra(operation: str, params: dict) -> dict: + if operation == "status": + return await infra_tool.status(params.get("host", "gpu")) + elif operation == "health": + return await infra_tool.health(params.get("service", "")) + elif operation == "disk": + return await infra_tool.disk(params.get("host", "")) + elif operation == "network": + return await infra_tool.network() + elif operation == "models": + return await infra_tool.models(params.get("host", "gpu")) + return _error("infra", operation, "미구현") + + def _error(tool: str, operation: str, msg: str) -> dict: return {"ok": False, "tool": tool, "operation": operation, "data": [], "summary": "", "error": msg}