# Agent Office — Docker 로그 통합 타임라인 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** agent-office LogTab에 5개 서비스 컨테이너의 의미 있는 docker 로그(액세스 로그 + 비즈니스 이벤트)를 통합 타임라인으로 표시하고, healthcheck/노이즈는 서버 측에서 차단한다. **Architecture:** 공용 `_shared/access_log.py` 모듈에 ring buffer + AccessLogMiddleware + BufferLogHandler + `/logs/recent` 엔드포인트를 두고, 5개 서비스가 한 줄(`install_access_log(app)`)로 적용한다. agent-office는 `service_proxy.fetch_service_logs()`로 polling해서 자체 `agent_logs` DB와 시간순 merge한 결과를 `/api/agent-office/agents/{id}/logs`로 반환한다. **Tech Stack:** FastAPI, Starlette middleware, Python `logging.Handler`, `collections.deque`, httpx, APScheduler, React (LogTab), Docker Compose, SQLite. **Spec:** `web-backend/docs/superpowers/specs/2026-05-28-agent-office-docker-logs-design.md` --- ## File Structure ### Creates ``` web-backend/_shared/__init__.py — 빈 패키지 선언 web-backend/_shared/access_log.py — Ring buffer + Middleware + Handler + Router + install() web-backend/_shared/tests/__init__.py web-backend/_shared/tests/test_access_log.py — 단위 테스트 web-backend/agent-office/tests/test_service_proxy_logs.py — fetch_service_logs 테스트 web-backend/agent-office/tests/test_log_merge.py — get_logs merge 테스트 ``` ### Modifies ``` web-backend/docker-compose.yml — 5개 서비스 PYTHONPATH/volume/logging web-backend/lotto/app/main.py — install_access_log(app) web-backend/stock/app/main.py — install_access_log(app) + logger.info 3개 web-backend/music-lab/app/main.py — install_access_log(app) + logger.info 2개 web-backend/insta-lab/app/main.py — install_access_log(app) + logger.info 3개 web-backend/realestate-lab/app/main.py — install_access_log(app) web-backend/agent-office/app/config.py — AGENT_CONTAINER_MAP 상수 web-backend/agent-office/app/service_proxy.py — fetch_service_logs(agent_id, ...) web-backend/agent-office/app/db.py — get_logs NOT LIKE 'State: %' + delete_old_logs web-backend/agent-office/app/main.py — /logs 엔드포인트 merge web-backend/agent-office/app/scheduler.py — cleanup job web-backend/agent-office/app/agents/base.py — add_log('State: ...') 제거 web-ui/src/pages/agent-office/components/LogTab.jsx — source 뱃지 + access 메타데이터 ``` 각 파일은 단일 책임. 5개 서비스의 main.py는 `install_access_log(app)` 한 줄만 추가하므로 변경 최소. --- # Phase 1 — PoC (lotto 단일 서비스에 적용) ## Task 1: `_shared/access_log.py` 모듈 + 단위 테스트 **Files:** - Create: `web-backend/_shared/__init__.py` - Create: `web-backend/_shared/access_log.py` - Create: `web-backend/_shared/tests/__init__.py` - Create: `web-backend/_shared/tests/test_access_log.py` - [ ] **Step 1: `_shared/__init__.py` 빈 파일 생성** ```python # empty ``` - [ ] **Step 2: `_shared/tests/__init__.py` 빈 파일 생성** ```python # empty ``` - [ ] **Step 3: 실패하는 테스트 작성 (`_shared/tests/test_access_log.py`)** ```python import logging import time from fastapi import FastAPI from fastapi.testclient import TestClient from _shared.access_log import ( AccessLogMiddleware, BufferLogHandler, router as logs_router, install, _BUFFER, ) def _reset_buffer(): _BUFFER.clear() def test_access_middleware_records_request(): _reset_buffer() app = FastAPI() app.add_middleware(AccessLogMiddleware) @app.get("/api/lotto/recommend") def recommend(): return {"ok": True} client = TestClient(app) client.get("/api/lotto/recommend") items = [x for x in _BUFFER if x["source"] == "access"] assert len(items) == 1 assert items[0]["method"] == "GET" assert items[0]["path"] == "/api/lotto/recommend" assert items[0]["status"] == 200 assert items[0]["ms"] >= 0 def test_access_middleware_skips_health(): _reset_buffer() app = FastAPI() app.add_middleware(AccessLogMiddleware) @app.get("/health") def health(): return {"ok": True} client = TestClient(app) client.get("/health") client.get("/healthz") if False else None # health 만 노출되어 있음 items = [x for x in _BUFFER if x["source"] == "access"] assert items == [] def test_access_middleware_skips_options(): _reset_buffer() app = FastAPI() app.add_middleware(AccessLogMiddleware) @app.get("/api/lotto/recommend") def recommend(): return {"ok": True} client = TestClient(app) client.options("/api/lotto/recommend") items = [x for x in _BUFFER if x["source"] == "access"] assert items == [] def test_buffer_log_handler_captures_logger_info(): _reset_buffer() root = logging.getLogger("") handler = BufferLogHandler() root.addHandler(handler) try: lg = logging.getLogger("lotto.test") lg.setLevel(logging.INFO) lg.info("뉴스 스크래핑 완료: 국내 12건") finally: root.removeHandler(handler) items = [x for x in _BUFFER if x["source"] == "log"] assert len(items) == 1 assert items[0]["message"] == "뉴스 스크래핑 완료: 국내 12건" assert items[0]["level"] == "info" assert items[0]["logger"] == "lotto.test" def test_logs_recent_endpoint_returns_recent_items(): _reset_buffer() app = FastAPI() install(app) @app.get("/api/lotto/recommend") def recommend(): return {"ok": True} client = TestClient(app) client.get("/api/lotto/recommend") client.get("/api/lotto/recommend") client.get("/health") # 제외되어야 함 resp = client.get("/logs/recent") assert resp.status_code == 200 logs = resp.json()["logs"] access_items = [x for x in logs if x["source"] == "access"] assert len(access_items) == 2 def test_logs_recent_with_since_filter(): _reset_buffer() app = FastAPI() install(app) @app.get("/api/lotto/recommend") def recommend(): return {"ok": True} client = TestClient(app) client.get("/api/lotto/recommend") time.sleep(0.01) cursor_resp = client.get("/logs/recent") cursor_ts = cursor_resp.json()["logs"][-1]["ts"] client.get("/api/lotto/recommend") resp = client.get(f"/logs/recent?since={cursor_ts}") items = [x for x in resp.json()["logs"] if x["source"] == "access"] assert len(items) == 1 ``` - [ ] **Step 4: 테스트 실행해서 실패 확인** Run: ```bash cd web-backend && python -m pytest _shared/tests/test_access_log.py -v ``` Expected: FAIL — `ModuleNotFoundError: No module named '_shared.access_log'` - [ ] **Step 5: `_shared/access_log.py` 구현** ```python """각 lab 컨테이너에서 import 하는 공용 액세스/이벤트 로그 모듈. 사용법: from _shared.access_log import install as install_access_log install_access_log(app) """ from collections import deque from datetime import datetime from typing import Optional import logging import time from fastapi import APIRouter, Request from fastapi.applications import FastAPI from starlette.middleware.base import BaseHTTPMiddleware # 컨테이너당 최근 500개를 in-memory 로 유지. 재시작 시 휘발. _BUFFER: deque = deque(maxlen=500) EXCLUDED_PATHS = { "/health", "/healthz", "/ping", "/favicon.ico", "/docs", "/redoc", "/openapi.json", "/logs/recent", } EXCLUDED_PREFIXES = ("/static/",) EXCLUDED_METHODS = {"OPTIONS", "HEAD"} def _should_log(request: Request) -> bool: if request.method in EXCLUDED_METHODS: return False path = request.url.path if path in EXCLUDED_PATHS: return False if any(path.startswith(p) for p in EXCLUDED_PREFIXES): return False return True class AccessLogMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): start = time.time() response = await call_next(request) if not _should_log(request): return response elapsed_ms = int((time.time() - start) * 1000) status = response.status_code if status < 400: level = "info" elif status < 500: level = "warning" else: level = "error" _BUFFER.append({ "ts": datetime.utcnow().isoformat() + "Z", "level": level, "source": "access", "method": request.method, "path": request.url.path, "status": status, "ms": elapsed_ms, "message": f"{request.method} {request.url.path} → {status} ({elapsed_ms}ms)", }) return response class BufferLogHandler(logging.Handler): """root logger 에 부착하면 모든 logger.info/warning/error 가 buffer 에 흐름.""" def emit(self, record: logging.LogRecord) -> None: try: _BUFFER.append({ "ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z", "level": record.levelname.lower(), "source": "log", "logger": record.name, "message": record.getMessage(), }) except Exception: # buffer 에 못 넣는다고 서비스가 죽으면 안 됨 pass router = APIRouter() @router.get("/logs/recent") def logs_recent(limit: int = 200, since: Optional[str] = None, path_prefix: Optional[str] = None): items = list(_BUFFER) if since: items = [x for x in items if x["ts"] > since] if path_prefix: items = [ x for x in items if x["source"] == "log" or x.get("path", "").startswith(path_prefix) ] return {"logs": items[-limit:]} def install(app: FastAPI, logger_root: str = "") -> None: """서비스 main.py 에서 호출하는 단일 설치 함수. - AccessLogMiddleware 등록 - /logs/recent 라우터 등록 - root logger 에 BufferLogHandler 부착 (모든 child logger 자동 전파) """ app.add_middleware(AccessLogMiddleware) app.include_router(router) root = logging.getLogger(logger_root) if not any(isinstance(h, BufferLogHandler) for h in root.handlers): root.addHandler(BufferLogHandler()) ``` - [ ] **Step 6: 테스트 통과 확인** Run: ```bash cd web-backend && python -m pytest _shared/tests/test_access_log.py -v ``` Expected: PASS — 6 passed. - [ ] **Step 7: 커밋** ```bash git add _shared/ git commit -m "feat(_shared): access_log 공용 모듈 추가 (ring buffer + middleware + /logs/recent)" ``` --- ## Task 2: `lotto/app/main.py`에 `install_access_log(app)` 추가 **Files:** - Modify: `web-backend/lotto/app/main.py` - [ ] **Step 1: lotto/app/main.py 상단 import 부분 확인** Run: ```bash head -30 web-backend/lotto/app/main.py ``` - [ ] **Step 2: `app = FastAPI(...)` 호출 직후 위치에 install_access_log 호출 추가** `lotto/app/main.py` 의 FastAPI 인스턴스 생성 직후 (CORS middleware 추가 자리쯤) 에 다음 두 줄 삽입: ```python from _shared.access_log import install as install_access_log install_access_log(app) ``` - [ ] **Step 3: 커밋** ```bash git add lotto/app/main.py git commit -m "feat(lotto): _shared/access_log install (Phase 1 PoC)" ``` --- ## Task 3: `docker-compose.yml` lotto 서비스에 PYTHONPATH + volume + logging 추가 **Files:** - Modify: `web-backend/docker-compose.yml` (lotto 서비스 블록만) - [ ] **Step 1: 현재 lotto 서비스 블록 확인** 위에서 확인된 라인 (3-23). environment, volumes 섹션을 확장. - [ ] **Step 2: lotto 서비스에 3가지 변경 적용** `docker-compose.yml` 의 `lotto:` 서비스 블록에서 다음 변경: ```yaml lotto: build: context: ./lotto args: APP_VERSION: ${APP_VERSION:-dev} container_name: lotto restart: unless-stopped ports: - "18000:8000" environment: - TZ=${TZ:-Asia/Seoul} - LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json} - LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json} - PYTHONPATH=/app:/shared # NEW volumes: - ${RUNTIME_PATH}/data:/app/data - ./_shared:/shared:ro # NEW logging: # NEW driver: "json-file" options: max-size: "10m" max-file: "3" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 60s timeout: 5s retries: 3 ``` - [ ] **Step 3: docker-compose 문법 검증 (NAS에서만 docker 구동 가능하므로 lint만)** Run (로컬에서): ```bash cd web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" ``` Expected: 에러 없음. - [ ] **Step 4: 커밋** ```bash git add docker-compose.yml git commit -m "build(lotto): PYTHONPATH=_shared + json-file logging 추가 (Phase 1 PoC)" ``` --- ## Task 4: `agent-office/app/config.py`에 `AGENT_CONTAINER_MAP` 추가 **Files:** - Modify: `web-backend/agent-office/app/config.py` - [ ] **Step 1: 현재 config.py 확인** Run: ```bash cat web-backend/agent-office/app/config.py | head -40 ``` - [ ] **Step 2: 파일 끝에 매핑 상수 추가** `agent-office/app/config.py` 끝에 다음 추가: ```python import re as _re # 에이전트 → (container_host, port, path_prefix_regex) # path_prefix_regex: lotto-backend 컨테이너에 personal/blog/todo 도 같이 있어 # /api/lotto 만 골라내기 위한 정규식. business log (source='log') 는 모두 통과. AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = { "lotto": ("lotto", 8000, _re.compile(r"^/api/lotto")), # Phase 2 에서 추가: # "stock": ("stock", 8000, _re.compile(r"^/api/(stock|trade|portfolio)")), # "music": ("music-lab", 8000, _re.compile(r"^/api/music")), # "insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")), # "realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")), } ``` - [ ] **Step 3: 커밋** ```bash git add agent-office/app/config.py git commit -m "feat(agent-office): AGENT_CONTAINER_MAP 상수 추가 (Phase 1 lotto)" ``` --- ## Task 5: `agent-office/app/service_proxy.py`에 `fetch_service_logs` + 테스트 **Files:** - Modify: `web-backend/agent-office/app/service_proxy.py` - Create: `web-backend/agent-office/tests/test_service_proxy_logs.py` - [ ] **Step 1: 실패하는 테스트 작성** `agent-office/tests/test_service_proxy_logs.py`: ```python import pytest import respx import httpx from app.service_proxy import fetch_service_logs @pytest.mark.asyncio @respx.mock async def test_fetch_service_logs_filters_by_path_prefix(): # lotto 컨테이너 응답: lotto + personal 섞임 respx.get("http://lotto:8000/logs/recent").mock( return_value=httpx.Response(200, json={ "logs": [ {"ts": "2026-05-28T10:00:00Z", "source": "access", "method": "GET", "path": "/api/lotto/recommend", "status": 200, "ms": 12, "message": "GET /api/lotto/recommend → 200 (12ms)"}, {"ts": "2026-05-28T10:00:01Z", "source": "access", "method": "GET", "path": "/api/blog/posts", "status": 200, "ms": 5, "message": "GET /api/blog/posts → 200 (5ms)"}, {"ts": "2026-05-28T10:00:02Z", "source": "log", "logger": "lotto", "level": "info", "message": "성과 통계 캐시 갱신"}, ] }) ) result = await fetch_service_logs("lotto", limit=50) # lotto path 와 모든 log 이벤트만 통과 paths = [x.get("path") for x in result] assert "/api/lotto/recommend" in paths assert "/api/blog/posts" not in paths # 비즈니스 로그도 포함 assert any(x["source"] == "log" and x["message"] == "성과 통계 캐시 갱신" for x in result) @pytest.mark.asyncio async def test_fetch_service_logs_unknown_agent_returns_empty(): result = await fetch_service_logs("nonexistent", limit=50) assert result == [] @pytest.mark.asyncio @respx.mock async def test_fetch_service_logs_handles_connection_error(): respx.get("http://lotto:8000/logs/recent").mock( side_effect=httpx.ConnectError("connection refused") ) result = await fetch_service_logs("lotto", limit=50) assert result == [] ``` - [ ] **Step 2: 테스트 실행해서 실패 확인** Run: ```bash cd web-backend/agent-office && python -m pytest tests/test_service_proxy_logs.py -v ``` Expected: FAIL — `ImportError: cannot import name 'fetch_service_logs'` `respx` 미설치 시: ```bash pip install respx pytest-asyncio ``` - [ ] **Step 3: `service_proxy.py` 끝에 함수 추가** ```python # 파일 상단에 logging import 추가 import logging logger = logging.getLogger(__name__) # 파일 하단에 추가: from .config import AGENT_CONTAINER_MAP async def fetch_service_logs( agent_id: str, since: Optional[str] = None, limit: int = 200, ) -> List[Dict[str, Any]]: """해당 에이전트가 가리키는 컨테이너의 /logs/recent 를 호출해서 path_prefix 정규식으로 필터한 결과를 반환. 네트워크 실패 시 빈 리스트를 반환하고 warning 만 남김 (LogTab 이 죽지 않게). """ mapping = AGENT_CONTAINER_MAP.get(agent_id) if not mapping: return [] host, port, path_re = mapping url = f"http://{host}:{port}/logs/recent" params: Dict[str, Any] = {"limit": limit} if since: params["since"] = since try: async with httpx.AsyncClient(timeout=3.0) as client: resp = await client.get(url, params=params) data = resp.json().get("logs", []) except Exception as e: logger.warning("fetch_service_logs(%s) 실패: %s", agent_id, e) return [] return [ x for x in data if x.get("source") == "log" or path_re.match(x.get("path", "") or "") ] ``` - [ ] **Step 4: 테스트 통과 확인** Run: ```bash cd web-backend/agent-office && python -m pytest tests/test_service_proxy_logs.py -v ``` Expected: PASS — 3 passed. - [ ] **Step 5: 커밋** ```bash git add agent-office/app/service_proxy.py agent-office/tests/test_service_proxy_logs.py git commit -m "feat(agent-office): fetch_service_logs 추가 (path_prefix 정규식 필터)" ``` --- ## Task 6: `agent-office/app/db.py` get_logs 필터 + delete_old_logs + 테스트 **Files:** - Modify: `web-backend/agent-office/app/db.py` - Modify: `web-backend/agent-office/app/test_db.py` - [ ] **Step 1: 실패하는 테스트 추가** `agent-office/app/test_db.py` 끝에 추가: ```python def test_get_logs_excludes_state_messages(): add_log("stock", "State: idle -> working (큐레이션 시작)") add_log("stock", "뉴스 12건 스크랩 완료") add_log("stock", "State: working -> idle ()") logs = get_logs("stock", limit=10) messages = [x["message"] for x in logs] assert "뉴스 12건 스크랩 완료" in messages assert not any(m.startswith("State: ") for m in messages) def test_delete_old_logs_removes_beyond_retention(): import datetime as _dt from app.db import delete_old_logs, _conn add_log("stock", "오래된 로그") # 강제로 200일 전으로 옮김 cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=200)).isoformat() with _conn() as conn: conn.execute( "UPDATE agent_logs SET created_at = ? WHERE message = '오래된 로그'", (cutoff,), ) add_log("stock", "최근 로그") deleted = delete_old_logs(days=90) assert deleted >= 1 msgs = [x["message"] for x in get_logs("stock", limit=20)] assert "최근 로그" in msgs assert "오래된 로그" not in msgs ``` - [ ] **Step 2: 테스트 실행해서 실패 확인** Run: ```bash cd web-backend/agent-office && python -m pytest app/test_db.py -v -k "state or old_logs" ``` Expected: FAIL — get_logs 가 State 포함, delete_old_logs 미정의. - [ ] **Step 3: `db.py` 수정 — get_logs 에 NOT LIKE 필터 추가** `db.py` 의 `get_logs` 함수 SQL 을: ```python def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( """ SELECT * FROM agent_logs WHERE agent_id = ? AND message NOT LIKE 'State: %' ORDER BY created_at DESC LIMIT ? """, (agent_id, limit), ).fetchall() return [ { "id": r["id"], "agent_id": r["agent_id"], "task_id": r["task_id"], "level": r["level"], "message": r["message"], "created_at": r["created_at"], "source": "agent", } for r in rows ] ``` (`source: "agent"` 필드도 추가 — 통합 타임라인에서 source 구분용) - [ ] **Step 4: `db.py` 끝에 `delete_old_logs` 함수 추가** ```python import datetime as _dt def delete_old_logs(days: int = 90) -> int: """retention 정책: 90일 이전 agent_logs 삭제. 매일 03:00 스케줄러가 호출.""" cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).isoformat() with _conn() as conn: c = conn.execute( "DELETE FROM agent_logs WHERE created_at < ?", (cutoff,), ) return c.rowcount ``` - [ ] **Step 5: 테스트 통과 확인** Run: ```bash cd web-backend/agent-office && python -m pytest app/test_db.py -v ``` Expected: PASS — 기존 + 추가 2개 모두 통과. - [ ] **Step 6: 커밋** ```bash git add agent-office/app/db.py agent-office/app/test_db.py git commit -m "feat(agent-office/db): get_logs에서 State: 자동 로그 제외 + delete_old_logs(90일)" ``` --- ## Task 7: `agent-office/app/main.py` agent_logs 엔드포인트 merge + 테스트 **Files:** - Modify: `web-backend/agent-office/app/main.py` - Create: `web-backend/agent-office/tests/test_log_merge.py` - [ ] **Step 1: 실패하는 테스트 작성** `agent-office/tests/test_log_merge.py`: ```python import pytest import respx import httpx from fastapi.testclient import TestClient from app.main import app from app.db import add_log, _conn @pytest.fixture(autouse=True) def _clean_logs(): with _conn() as conn: conn.execute("DELETE FROM agent_logs WHERE agent_id = 'lotto'") yield @respx.mock def test_agent_logs_endpoint_merges_db_and_service_logs(): add_log("lotto", "큐레이션 완료: #1234 conf=0.78") respx.get("http://lotto:8000/logs/recent").mock( return_value=httpx.Response(200, json={ "logs": [ {"ts": "2026-05-28T10:00:00Z", "source": "access", "method": "GET", "path": "/api/lotto/latest", "status": 200, "ms": 8, "message": "GET /api/lotto/latest → 200 (8ms)"}, {"ts": "2026-05-28T10:00:02Z", "source": "log", "logger": "lotto", "level": "info", "message": "성과 통계 캐시 갱신"}, ] }) ) client = TestClient(app) resp = client.get("/api/agent-office/agents/lotto/logs?limit=20") assert resp.status_code == 200 logs = resp.json()["logs"] sources = {x["source"] for x in logs} assert "agent" in sources assert "access" in sources assert "log" in sources messages = [x["message"] for x in logs] assert any("큐레이션 완료" in m for m in messages) assert any("성과 통계 캐시 갱신" in m for m in messages) assert any("/api/lotto/latest" in m for m in messages) ``` - [ ] **Step 2: 테스트 실행해서 실패 확인** Run: ```bash cd web-backend/agent-office && python -m pytest tests/test_log_merge.py -v ``` Expected: FAIL — 현재 엔드포인트는 service 로그를 fetch 하지 않음. - [ ] **Step 3: `agent-office/app/main.py` 의 `agent_logs` 엔드포인트 교체** 기존 (line 118-120): ```python @app.get("/api/agent-office/agents/{agent_id}/logs") def agent_logs(agent_id: str, limit: int = 50): return {"logs": get_logs(agent_id, limit)} ``` → 다음으로 교체: ```python @app.get("/api/agent-office/agents/{agent_id}/logs") async def agent_logs(agent_id: str, limit: int = 50): from .service_proxy import fetch_service_logs agent_items = get_logs(agent_id, limit=limit) service_items = await fetch_service_logs(agent_id, limit=limit) def _sort_key(x): # agent_logs: created_at, service: ts return x.get("ts") or x.get("created_at") or "" merged = sorted(agent_items + service_items, key=_sort_key, reverse=True) return {"logs": merged[:limit]} ``` - [ ] **Step 4: 테스트 통과 확인** Run: ```bash cd web-backend/agent-office && python -m pytest tests/test_log_merge.py -v ``` Expected: PASS. - [ ] **Step 5: 커밋** ```bash git add agent-office/app/main.py agent-office/tests/test_log_merge.py git commit -m "feat(agent-office): /agents/{id}/logs 엔드포인트가 service /logs/recent 와 merge" ``` --- ## Task 8: `agent-office/app/agents/base.py` transition 의 add_log 제거 **Files:** - Modify: `web-backend/agent-office/app/agents/base.py` - [ ] **Step 1: 현재 transition 메서드 확인** `base.py` 의 `transition()` 메서드 (line 22-43) 의 32번 라인: ```python add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})") ``` - [ ] **Step 2: 해당 라인 삭제** `add_log(self.agent_id, f"State: ...")` 라인 1줄과 위쪽 빈 줄 1줄을 제거. 또한 파일 상단의 `from ..db import add_log` 도 다른 곳에서 더 이상 쓰지 않으면 제거. (주의: db.py 의 add_log 자체는 다른 모듈이 계속 쓰므로 함수는 유지) ```python import time from typing import Optional VALID_STATES = ("idle", "working", "waiting", "reporting") class BaseAgent: agent_id: str = "" display_name: str = "" state: str = "idle" state_detail: str = "" _idle_since: float = 0.0 _ws_manager = None def __init__(self): self._idle_since = time.time() def set_ws_manager(self, manager): self._ws_manager = manager async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None: if new_state not in VALID_STATES: return old = self.state self.state = new_state self.state_detail = detail if new_state == "idle": self._idle_since = time.time() if self._ws_manager: await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id) if new_state == "working" and old != "working": await self._ws_manager.send_notification( self.agent_id, "task_assigned", task_id, detail or "새 작업 시작" ) elif new_state == "idle" and old in ("working", "reporting"): await self._ws_manager.send_notification( self.agent_id, "task_completed", task_id, detail or "작업 완료" ) # ... 나머지 메서드는 그대로 ``` - [ ] **Step 3: 기존 agent_logs DB 의 state 로그 일괄 삭제 (선택, 일회성)** 운영 DB 에 누적된 `State: ...` 행을 한 번 정리. Run (NAS 에서 또는 로컬 DB 에서): ```bash sqlite3 path/to/agent_office.db "DELETE FROM agent_logs WHERE message LIKE 'State: %'" ``` (이 작업은 plan 진행자가 NAS 접근권한 있을 때 직접 수행. CI 에서는 생략.) - [ ] **Step 4: 기존 테스트가 깨지지 않는지 전체 확인** Run: ```bash cd web-backend/agent-office && python -m pytest -v ``` Expected: 기존 모든 테스트 통과. - [ ] **Step 5: 커밋** ```bash git add agent-office/app/agents/base.py git commit -m "refactor(agent-office/base): transition의 State 자동 로그 제거" ``` --- ## Task 9: `web-ui/src/pages/agent-office/components/LogTab.jsx` source 뱃지 + access 메타데이터 **Files:** - Modify: `web-ui/src/pages/agent-office/components/LogTab.jsx` - [ ] **Step 1: 새 LogTab.jsx 작성** `web-ui/src/pages/agent-office/components/LogTab.jsx` 전체를 다음으로 교체: ```jsx // src/pages/agent-office/components/LogTab.jsx import { useState, useEffect, useRef } from 'react'; import { getAgentLogs } from '../../../api'; const LEVEL_STYLE = { info: { color: '#60a5fa' }, warning: { color: '#fbbf24' }, error: { color: '#ef4444' }, }; const SOURCE_STYLE = { agent: { color: '#9ca3af', label: 'AGENT' }, access: { color: '#5eead4', label: 'ACCESS' }, log: { color: '#a78bfa', label: 'LOG' }, }; function formatTime(iso) { if (!iso) return ''; return new Date(iso).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } export default function LogTab({ agentId, refreshTrigger }) { const [logs, setLogs] = useState([]); const scrollRef = useRef(null); useEffect(() => { let cancelled = false; const fetchLogs = () => { getAgentLogs(agentId, 100).then(data => { if (cancelled) return; setLogs(Array.isArray(data) ? data : (data?.logs || [])); }).catch(() => {}); }; fetchLogs(); const interval = setInterval(fetchLogs, 5000); // 5초 폴링 return () => { cancelled = true; clearInterval(interval); }; }, [agentId, refreshTrigger]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [logs]); return (