From 1490bd5f245da95e7911b3ad09665e2499996c6a Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 14 May 2026 10:11:51 +0800 Subject: [PATCH] feat: unify runtime logging (rotating files, log root, scripts, desktop) Co-authored-by: Cursor --- README.md | 4 + _local/system.env.example | 13 ++ desktop/README.md | 7 +- desktop/main.js | 10 +- docs/ENVIRONMENT_VARIABLES.md | 37 +++- docs/LOGGING.md | 43 ++++ interfaces/channels/wecom/longconn_runner.py | 5 + interfaces/http/fastapi_app.py | 26 ++- runtime/hooks/bundled/boot-md/handler.py | 6 +- .../hooks/bundled/command-logger/handler.py | 6 +- runtime/operations/runtime.py | 8 +- .../scripts/lib/ResolveRuntimeLogDir.ps1 | 23 +++ runtime/operations/scripts/start_desktop.ps1 | 10 +- runtime/operations/scripts/start_gateway.ps1 | 10 +- .../operations/scripts/start_wiki_worker.ps1 | 8 +- runtime/operations/scripts/status_desktop.ps1 | 15 +- runtime/operations/scripts/weixin_install.ps1 | 1 - runtime/operations/scripts/weixin_start.ps1 | 11 +- .../operations/scripts/whatsapp_install.ps1 | 2 - runtime/operations/scripts/whatsapp_start.ps1 | 11 +- runtime/workers/wiki/main.py | 5 + svc/config/log_paths.py | 40 ++++ svc/observability/__init__.py | 9 + svc/observability/logging_setup.py | 193 ++++++++++++++++++ tests/test_logging_setup.py | 91 +++++++++ 25 files changed, 541 insertions(+), 53 deletions(-) create mode 100644 docs/LOGGING.md create mode 100644 runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1 create mode 100644 svc/config/log_paths.py create mode 100644 svc/observability/__init__.py create mode 100644 svc/observability/logging_setup.py create mode 100644 tests/test_logging_setup.py diff --git a/README.md b/README.md index 6816529e..eb0d32f3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ This repository is fully consolidated under `oclaw/`. 3. **助手主库(PostgreSQL,可选)**:默认使用本地 SQLite;若要将助手持久化迁到 PostgreSQL 或从零搭 PG,步骤与脚本说明见 `docs/ASSISTANT_PG_MIGRATION.md`(环境变量仍以 `docs/ENVIRONMENT_VARIABLES.md` 为准)。 +4. **日志**:运行与轮转日志目录、排障时查看哪些文件,见 `docs/LOGGING.md`。 + ### 命令(仓库根目录执行) ```powershell @@ -129,6 +131,7 @@ WhatsApp install/start guide: see `docs/RUNBOOK.md` → “4.2 WhatsApp(实验 Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`. Branding guide (external-first assets under `_local/branding`): `docs/BRANDING.md`. Assistant main store: SQLite by default; optional PostgreSQL setup and SQLite→PG cutover: `docs/ASSISTANT_PG_MIGRATION.md`. +Runtime logging layout and troubleshooting: `docs/LOGGING.md`. Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README. @@ -172,6 +175,7 @@ Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at See `docs/ENVIRONMENT_VARIABLES.md` for full runtime variable reference. PostgreSQL assistant-store migration (schema, data import, cutover): `docs/ASSISTANT_PG_MIGRATION.md`. +Logging directories and rotating files: `docs/LOGGING.md`. ## License and security diff --git a/_local/system.env.example b/_local/system.env.example index 1035cc44..c2a659b0 100644 --- a/_local/system.env.example +++ b/_local/system.env.example @@ -279,8 +279,21 @@ AIA_MAX_ATTACHMENT_BYTES= # ----------------------------------------------------------------------------- # 十二、路由与运行时日志 # ----------------------------------------------------------------------------- +# 详见 docs/LOGGING.md。根目录默认:/logs(常为 data/logs/)。 AIA_OCLAW_ROUTER_MODE= AIA_RUNTIME_LOG_DIR= +# OCLAW_DESKTOP_LOG_ROOT 可选;Electron 写 desktop.log/backend.log 的根;start_desktop.ps1 会设为与 AIA_RUNTIME_LOG_DIR 相同。 +OCLAW_DESKTOP_LOG_ROOT= +# OCLAW_LOG_LEVEL / AIA_LOG_LEVEL 应用与 uvicorn 文件日志级别,默认 INFO。 +# OCLAW_LOG_MAX_BYTES 单文件上限(字节),默认 20971520(20 MiB)。 +# OCLAW_LOG_BACKUP_COUNT 轮转保留份数,默认 5。 +# AIA_LOG_TO_FILE 设为 0/false/off 时不写 app/*.log(pytest 也会跳过文件 handler)。 +# OCLAW_HOOK_LOG_USE_STATE_DIR 设为 1/true 时,command-logger / boot-md 改回 ~/.oclaw/logs(或 OCLAW_STATE_DIR/logs);默认写入运行日志根下 hooks/。 +OCLAW_LOG_LEVEL= +OCLAW_LOG_MAX_BYTES= +OCLAW_LOG_BACKUP_COUNT= +AIA_LOG_TO_FILE= +OCLAW_HOOK_LOG_USE_STATE_DIR= # ----------------------------------------------------------------------------- # 十三、RAG / 记忆 diff --git a/desktop/README.md b/desktop/README.md index e68c1d9e..327159dd 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -27,9 +27,12 @@ The app will: 2. Start backend using `python -m runtime.operations gateway start --host 127.0.0.1 --port `. 3. Open `http://127.0.0.1:/chat` in the desktop window. -Logs are written under: +Logs (embedded backend, desktop shell, optional WeCom channel child) are written under the **runtime log root** (same as `AIA_RUNTIME_LOG_DIR` / `data/logs/` — see repo root [`docs/LOGGING.md`](../docs/LOGGING.md)). When launched via `runtime/operations/scripts/start_desktop.ps1`, `OCLAW_DESKTOP_LOG_ROOT` is set for you. -- `%APPDATA%/oclaw/logs/backend.log` (Windows) +Typical files: + +- `desktop.log` +- `backend.log` ## Environment knobs diff --git a/desktop/main.js b/desktop/main.js index 89ae4f34..af609790 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -23,8 +23,16 @@ const APP_ICON_PATH = (() => { } return candidates[candidates.length - 1]; })(); +const REPO_ROOT_FOR_LOGS = path.resolve(__dirname, ".."); +function resolveFileLogRoot() { + const fromEnv = String( + process.env.OCLAW_DESKTOP_LOG_ROOT || process.env.AIA_RUNTIME_LOG_DIR || "" + ).trim(); + if (fromEnv) return path.resolve(fromEnv); + return path.join(REPO_ROOT_FOR_LOGS, "data", "logs"); +} const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data"); -const LOG_ROOT = path.join(app.getPath("userData"), "logs"); +const LOG_ROOT = resolveFileLogRoot(); const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log"); const STARTUP_TIMEOUT_MS = 30000; const POLL_INTERVAL_MS = 600; diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index b67543c5..fa76a78b 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -519,9 +519,40 @@ - 生效:`oclaw/interfaces/http/fastapi_app.py` - `AIA_RUNTIME_LOG_DIR` - - 默认:空(使用内部默认目录) - - 作用:运行日志目录 - - 生效:`oclaw/runtime/operations/runtime.py` + - 默认:空(使用 `/logs`,常见为 `data/logs/`) + - 作用:运行日志根目录(网关、渠道、wiki worker、微信/WhatsApp sidecar 重定向、Desktop 文件日志、Hook `hooks/` 等) + - 说明:布局与排障见 [`LOGGING.md`](./LOGGING.md) + - 生效:`oclaw/svc/config/log_paths.py`, `oclaw/runtime/operations/runtime.py`, `oclaw/svc/observability/logging_setup.py`, `oclaw/interfaces/http/fastapi_app.py`, `oclaw/desktop/main.js`, `oclaw/runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1` 及依赖该库的启动脚本 + +- `OCLAW_DESKTOP_LOG_ROOT` + - 默认:空(Desktop 使用 `AIA_RUNTIME_LOG_DIR`;再空则 `<仓库根>/data/logs/`,见 `desktop/main.js`) + - 作用:Electron 内嵌网关等写入 `desktop.log` / `backend.log` 等的目录;`start_desktop.ps1` 会设为与 `AIA_RUNTIME_LOG_DIR` 相同 + - 生效:`oclaw/desktop/main.js`, `oclaw/runtime/operations/scripts/start_desktop.ps1` + +- `OCLAW_LOG_LEVEL` / `AIA_LOG_LEVEL` + - 默认:`INFO` + - 作用:写入 `app/oclaw.log`(及 uvicorn 相关 logger)的级别;取值 `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`(`WARN`→`WARNING`,`FATAL`→`CRITICAL`) + - 生效:`oclaw/svc/observability/logging_setup.py` + +- `OCLAW_LOG_MAX_BYTES` + - 默认:`20971520`(20 MiB) + - 作用:单一日志文件达到该大小后轮转 + - 生效:`oclaw/svc/observability/logging_setup.py` + +- `OCLAW_LOG_BACKUP_COUNT` + - 默认:`5` + - 作用:轮转文件保留数量(含当前文件在内的上限由 handler 实现决定) + - 生效:`oclaw/svc/observability/logging_setup.py` + +- `AIA_LOG_TO_FILE` + - 默认:未设置(启用文件日志;pytest 进程内仍会因 `PYTEST_CURRENT_TEST` 跳过写文件) + - 作用:设为 `0` / `false` / `no` / `off` 时不配置轮转文件 handler(便于前台调试或特殊环境) + - 生效:`oclaw/svc/observability/logging_setup.py`, `oclaw/interfaces/http/fastapi_app.py`(网关不传 `log_config`) + +- `OCLAW_HOOK_LOG_USE_STATE_DIR` + - 默认:未设置(`command-logger` / `boot-md` 写入运行日志根下的 `hooks/`,与 `data/logs` 同树) + - 作用:设为 `1` / `true` / `yes` / `on` 时,改回旧布局:写入 `OCLAW_STATE_DIR`(或 `~/.oclaw`)下的 `logs/` + - 生效:`oclaw/runtime/hooks/bundled/command-logger/handler.py`, `oclaw/runtime/hooks/bundled/boot-md/handler.py`, `oclaw/svc/config/log_paths.py` - `AIA_SSE_QUEUE_MAXSIZE` - 默认:`2000` diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 00000000..217045ce --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,43 @@ +# 日志与排障 + +本文说明 Oclaw **日志根目录**、进程级日志与 **Python 轮转日志** 的落点,便于线上/本机排障。环境变量完整列表仍以 [`ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md) 为准。 + +## 日志根目录 + +由 **`AIA_RUNTIME_LOG_DIR`** 指定;未设置时与 `runtime.operations.runtime.assistant_runtime_log_dir()` 一致:**助手库文件(`db_path()`)所在目录下的 `logs/`**(常见为仓库下 `data/logs/`)。 + +网关、`stack up` 启用的渠道进程、**轮转应用日志**、**内置 Hook 文件日志**(`hooks/`)、**微信/WhatsApp sidecar 的 stdout/stderr**(`weixin_sidecar.*` / `whatsapp_sidecar.*`),以及 **Desktop(Electron)** 写入的 `desktop.log` / `backend.log` / `channel-wecom.log`,均默认使用该根目录(Desktop 由 `start_desktop.ps1` 设置 `AIA_RUNTIME_LOG_DIR` / `OCLAW_DESKTOP_LOG_ROOT`,或直接运行 Electron 时回落到 **`<仓库根>/data/logs/`**)。Wiki worker 若用 `start_wiki_worker.ps1` 后台启动,其 stdout/stderr 为 `wiki_worker.out.log` / `wiki_worker.err.log`。 + +## 目录布局(根目录下) + +| 路径 | 说明 | +|------|------| +| `gateway.out.log` / `gateway.err.log` | `start_service` 重定向子进程的 stdout/stderr;裸 `print`、未走 `logging` 的输出等。**追加写,无应用内轮转**;生产可配合系统 logrotate。 | +| `channel_*.out.log` / `.err.log` | 同上,渠道名中的 `:` 会替换为 `_`。 | +| `app/oclaw.log` | **轮转**:应用与 uvicorn 主日志(`RotatingFileHandler`)。 | +| `app/uvicorn-access.log` | **轮转**:HTTP access(仅网关 uvicorn 模式)。 | +| `weixin_sidecar.log` / `weixin_sidecar.err.log` | `weixin_start.ps1` 重定向 sidecar 进程输出(原在 `data/channel_sidecar/.../logs`,已统一到运行日志根)。 | +| `whatsapp_sidecar.log` / `whatsapp_sidecar.err.log` | `whatsapp_start.ps1` 同上。 | +| `desktop.log` / `backend.log` / `channel-wecom.log` | Desktop Electron 内嵌网关与渠道子进程日志(`desktop/main.js`)。 | +| `wiki_worker.out.log` / `wiki_worker.err.log` | `start_wiki_worker.ps1` 后台模式重定向的 stdout/stderr(与运行日志根一致)。 | +| `hooks/commands.log`、`boot-md.log` | 默认在 **`{运行日志根}/hooks/`**(与网关同根)。若需恢复旧行为(`OCLAW_STATE_DIR` 或 `~/.oclaw/logs`),设置 **`OCLAW_HOOK_LOG_USE_STATE_DIR=1`**。 | + +## 环境变量(摘要) + +| 变量 | 作用 | +|------|------| +| `AIA_RUNTIME_LOG_DIR` | 上述根目录。 | +| `OCLAW_LOG_LEVEL` / `AIA_LOG_LEVEL` | 文件日志级别,默认 `INFO`(支持 `DEBUG`、`WARNING`、`ERROR`、`CRITICAL` 等)。 | +| `OCLAW_LOG_MAX_BYTES` | 单文件最大字节,默认 `20971520`(20 MiB)。 | +| `OCLAW_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `5`。 | +| `AIA_LOG_TO_FILE` | 设为 `0` / `false` / `off` 等时**不写**轮转文件(pytest 也会自动跳过文件 handler)。 | +| `OCLAW_DESKTOP_LOG_ROOT` | 可选;Desktop 写入 `desktop.log` / `backend.log` 等的目录。未设置时读 `AIA_RUNTIME_LOG_DIR`;再未设置则用 **`<仓库根>/data/logs/`**(与 `desktop/main.js` 一致)。`start_desktop.ps1` 会将其设为与 `AIA_RUNTIME_LOG_DIR` 相同。 | +| `OCLAW_HOOK_LOG_USE_STATE_DIR` | 设为 `1` / `true` 时,内置钩子日志改写到 **`OCLAW_STATE_DIR`(或 `~/.oclaw`)下的 `logs/`**;未设置时默认 **`{运行日志根}/hooks/`**。 | + +## 实现位置 + +- 根路径:[`svc/config/log_paths.py`](../svc/config/log_paths.py)(`oclaw_log_root`、`oclaw_hooks_log_dir`) +- 配置:[`svc/observability/logging_setup.py`](../svc/observability/logging_setup.py) +- 网关:[`interfaces/http/fastapi_app.py`](../interfaces/http/fastapi_app.py) 的 `main()` 在 `uvicorn.run(..., log_config=...)` +- PowerShell 解析与网关相同的根目录:[`runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1`](../runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1)(`Get-OclawRuntimeLogDir`) +- 企微 longconn / wiki worker:进程入口 `configure_oclaw_logging(...)` diff --git a/interfaces/channels/wecom/longconn_runner.py b/interfaces/channels/wecom/longconn_runner.py index 27e94dfe..e090e24f 100644 --- a/interfaces/channels/wecom/longconn_runner.py +++ b/interfaces/channels/wecom/longconn_runner.py @@ -650,6 +650,11 @@ def _run_ws_forever(*, sender: WeComClient, deliver_outbound: bool, use_response def run_forever() -> int: + from svc.config.bootstrap_env import load_system_env + from svc.observability.logging_setup import configure_oclaw_logging + + load_system_env() + configure_oclaw_logging(service_name="wecom-longconn", include_uvicorn_formatters=False) store = get_assistant_store() sender = WeComClient(store) lock = _SingleInstanceLock(Path(db_path()).resolve().parent / "locks" / "wecom_longconn.lock") diff --git a/interfaces/http/fastapi_app.py b/interfaces/http/fastapi_app.py index 202d2501..5f5dc753 100644 --- a/interfaces/http/fastapi_app.py +++ b/interfaces/http/fastapi_app.py @@ -300,6 +300,13 @@ def create_app() -> FastAPI: def main() -> int: load_system_env() + from svc.observability.logging_setup import ( + build_uvicorn_logging_dict_config, + configure_oclaw_logging, + skip_file_logging, + ) + + configure_oclaw_logging(service_name="gateway", include_uvicorn_formatters=True) host = (os.getenv("AIA_ASSISTANT_GATEWAY_HOST") or "0.0.0.0").strip() port = int(os.getenv("AIA_ASSISTANT_GATEWAY_PORT") or "8787") try: @@ -313,14 +320,17 @@ def main() -> int: except Exception: ws_max_size = int(MAX_PAYLOAD_BYTES) ws_max_size = max(1024, min(int(ws_max_size), 200_000_000)) - uvicorn.run( - "interfaces.http.fastapi_app:create_app", - host=host, - port=port, - reload=False, - factory=True, - ws_max_size=ws_max_size, - ) + run_kw: dict[str, Any] = { + "app": "interfaces.http.fastapi_app:create_app", + "host": host, + "port": port, + "reload": False, + "factory": True, + "ws_max_size": ws_max_size, + } + if not skip_file_logging(): + run_kw["log_config"] = build_uvicorn_logging_dict_config() + uvicorn.run(**run_kw) return 0 diff --git a/runtime/hooks/bundled/boot-md/handler.py b/runtime/hooks/bundled/boot-md/handler.py index 4fe8d41b..ed4f1320 100644 --- a/runtime/hooks/bundled/boot-md/handler.py +++ b/runtime/hooks/bundled/boot-md/handler.py @@ -5,6 +5,8 @@ import os from pathlib import Path from typing import Any, Iterable +from svc.config.log_paths import oclaw_hooks_log_dir + def _resolve_state_dir() -> Path: override = os.environ.get("OCLAW_STATE_DIR") or os.environ.get("OCLAW_HOME") @@ -49,9 +51,7 @@ def handle(event: Any) -> None: if getattr(event, "type", None) != "gateway" or getattr(event, "action", None) != "startup": return - state = _resolve_state_dir() - log_dir = state / "logs" - log_dir.mkdir(parents=True, exist_ok=True) + log_dir = oclaw_hooks_log_dir(state_dir_if_legacy=_resolve_state_dir()) out_log = log_dir / "boot-md.log" now = getattr(event, "timestamp", None) diff --git a/runtime/hooks/bundled/command-logger/handler.py b/runtime/hooks/bundled/command-logger/handler.py index e04a2913..906bd9ed 100644 --- a/runtime/hooks/bundled/command-logger/handler.py +++ b/runtime/hooks/bundled/command-logger/handler.py @@ -5,6 +5,8 @@ import os from pathlib import Path from typing import Any, Dict +from svc.config.log_paths import oclaw_hooks_log_dir + def _resolve_state_dir() -> Path: # Keep this compatible with typical Oclaw layouts. @@ -18,9 +20,7 @@ def handle(event) -> None: if getattr(event, "type", None) != "command": return - state_dir = _resolve_state_dir() - log_dir = state_dir / "logs" - log_dir.mkdir(parents=True, exist_ok=True) + log_dir = oclaw_hooks_log_dir(state_dir_if_legacy=_resolve_state_dir()) payload: Dict[str, Any] = { "timestamp": getattr(getattr(event, "timestamp", None), "isoformat", lambda: None)(), diff --git a/runtime/operations/runtime.py b/runtime/operations/runtime.py index f3b7622f..7a2783c2 100644 --- a/runtime/operations/runtime.py +++ b/runtime/operations/runtime.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from svc.config.log_paths import oclaw_log_root from svc.config.paths import db_path @@ -17,10 +18,7 @@ def _runtime_file() -> Path: def _runtime_log_dir() -> Path: - p = str(os.getenv("AIA_RUNTIME_LOG_DIR") or "").strip() - if p: - return Path(p).expanduser().resolve() - return Path(db_path()).resolve().parent / "logs" + return oclaw_log_root() def assistant_runtime_log_dir() -> Path: @@ -29,7 +27,7 @@ def assistant_runtime_log_dir() -> Path: Same rule as ``AIA_RUNTIME_LOG_DIR`` or ``)/logs``. Use this from shell scripts so paths match Python ``stack up`` / ``start_service``. """ - return _runtime_log_dir() + return oclaw_log_root() def _read_state() -> dict[str, Any]: diff --git a/runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1 b/runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1 new file mode 100644 index 00000000..d2aec8e2 --- /dev/null +++ b/runtime/operations/scripts/lib/ResolveRuntimeLogDir.ps1 @@ -0,0 +1,23 @@ +# Shared: resolve the same directory as Python ``assistant_runtime_log_dir()`` / ``AIA_RUNTIME_LOG_DIR``. +function Get-OclawRuntimeLogDir { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot + ) + $venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe" + $pythonExe = if (Test-Path $venvPython) { $venvPython } else { "python" } + $savedPyPath = $env:PYTHONPATH + $env:PYTHONPATH = $RepoRoot + $dir = $null + try { + $dir = (& $pythonExe -c "from runtime.operations.runtime import assistant_runtime_log_dir; print(str(assistant_runtime_log_dir()))" 2>$null | Select-Object -Last 1).Trim() + } catch { } + if ($null -ne $savedPyPath) { + $env:PYTHONPATH = $savedPyPath + } else { + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + } + if (-not $dir) { + $dir = Join-Path $RepoRoot "data\logs" + } + return $dir +} diff --git a/runtime/operations/scripts/start_desktop.ps1 b/runtime/operations/scripts/start_desktop.ps1 index c3437a50..7897d6a1 100644 --- a/runtime/operations/scripts/start_desktop.ps1 +++ b/runtime/operations/scripts/start_desktop.ps1 @@ -12,8 +12,6 @@ $desktopDir = Join-Path $repoRoot "desktop" $runDir = Join-Path $PSScriptRoot ".run" New-Item -ItemType Directory -Force -Path $runDir | Out-Null $pidFile = Join-Path $runDir "desktop.pid" -$outLog = Join-Path $runDir "desktop.out.log" -$errLog = Join-Path $runDir "desktop.err.log" if (-not (Test-Path $desktopDir)) { Write-Host "[ERROR] desktop directory not found: $desktopDir" -ForegroundColor Red @@ -27,6 +25,12 @@ $env:AIA_WORKSPACE_ROOT = $repoRoot $env:OPS_WORKSPACE_ROOT = $repoRoot $env:OCLAW_WORKSPACE = $repoRoot +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot +$env:AIA_RUNTIME_LOG_DIR = $runtimeLogDir +$env:OCLAW_DESKTOP_LOG_ROOT = $runtimeLogDir +New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null + # Do not inherit attach-mode env from a parent shell when launching standalone desktop. Remove-Item Env:OCLAW_DESKTOP_EMBED_BACKEND -ErrorAction SilentlyContinue Remove-Item Env:OCLAW_DESKTOP_GATEWAY_BASE_URL -ErrorAction SilentlyContinue @@ -73,7 +77,7 @@ if ($Background) { } Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii Write-Host "desktop.pid = $pidFile" -ForegroundColor DarkGray - Write-Host "Embedded backend/chat logs (Electron): see %APPDATA%\oclaw\logs\desktop.log / backend.log" -ForegroundColor DarkGray + Write-Host "Desktop / embedded gateway file logs: $runtimeLogDir\desktop.log , backend.log , channel-wecom.log" -ForegroundColor DarkGray Write-Host "PID = $($p.Id)" -ForegroundColor Green Write-Host "==> Desktop launched in background (this script exits now)." -ForegroundColor Cyan exit 0 diff --git a/runtime/operations/scripts/start_gateway.ps1 b/runtime/operations/scripts/start_gateway.ps1 index 0e732d86..0d891118 100644 --- a/runtime/operations/scripts/start_gateway.ps1 +++ b/runtime/operations/scripts/start_gateway.ps1 @@ -77,14 +77,8 @@ if (-not $SkipInstall) { Write-Step "Skip dependency install" } -# Same directory as runtime.operations.runtime.start_service (db_path parent / logs, or AIA_RUNTIME_LOG_DIR). -$logDir = $null -try { - $logDir = (& $pythonExe -c "from runtime.operations.runtime import assistant_runtime_log_dir; print(str(assistant_runtime_log_dir()))" 2>$null | Select-Object -Last 1).Trim() -} catch { } -if (-not $logDir) { - $logDir = Join-Path $repoRoot "data\logs" -} +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot Write-Step "Runtime log dir: $logDir" Write-Host " (stack up / start_service: gateway.err.log + gateway.out.log here)" -ForegroundColor DarkGray Write-Host " (this script foreground: gateway.foreground.log when log mirror on)" -ForegroundColor DarkGray diff --git a/runtime/operations/scripts/start_wiki_worker.ps1 b/runtime/operations/scripts/start_wiki_worker.ps1 index 729e6afb..6835a0f2 100644 --- a/runtime/operations/scripts/start_wiki_worker.ps1 +++ b/runtime/operations/scripts/start_wiki_worker.ps1 @@ -13,8 +13,6 @@ $repoRoot = Resolve-RepoRoot $runDir = Join-Path $PSScriptRoot ".run" $null = New-Item -ItemType Directory -Force -Path $runDir -ErrorAction SilentlyContinue $pidFile = Join-Path $runDir "wiki_worker.pid" -$outLog = Join-Path $runDir "wiki_worker.out.log" -$errLog = Join-Path $runDir "wiki_worker.err.log" function Test-AlivePid([int]$procId) { try { @@ -51,6 +49,12 @@ $env:AIA_WORKSPACE_ROOT = $repoRoot $env:OPS_WORKSPACE_ROOT = $repoRoot $env:OCLAW_WORKSPACE = $repoRoot +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot +$null = New-Item -ItemType Directory -Force -Path $logDir -ErrorAction SilentlyContinue +$outLog = Join-Path $logDir "wiki_worker.out.log" +$errLog = Join-Path $logDir "wiki_worker.err.log" + if (-not $Background) { & $pythonExe -m runtime.workers.wiki.main exit 0 diff --git a/runtime/operations/scripts/status_desktop.ps1 b/runtime/operations/scripts/status_desktop.ps1 index 32d99d47..8b6ec425 100644 --- a/runtime/operations/scripts/status_desktop.ps1 +++ b/runtime/operations/scripts/status_desktop.ps1 @@ -6,10 +6,16 @@ function Warn([string]$msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) +$env:PYTHONPATH = $repoRoot +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$logDir = Get-OclawRuntimeLogDir -RepoRoot $repoRoot + $runDir = Join-Path $PSScriptRoot ".run" $pidFile = Join-Path $runDir "desktop.pid" -$outLog = Join-Path $runDir "desktop.out.log" -$errLog = Join-Path $runDir "desktop.err.log" +$desktopLog = Join-Path $logDir "desktop.log" +$backendLog = Join-Path $logDir "backend.log" +$channelWecomLog = Join-Path $logDir "channel-wecom.log" if (-not (Test-Path $pidFile)) { Warn "desktop.pid not found: $pidFile" @@ -29,8 +35,9 @@ try { Write-Host "desktop_running=1" -ForegroundColor Green Write-Host "pid=$procId" Write-Host "name=$($p.ProcessName)" - if (Test-Path $outLog) { Write-Host "out_log=$outLog" } - if (Test-Path $errLog) { Write-Host "err_log=$errLog" } + if (Test-Path $desktopLog) { Write-Host "desktop_log=$desktopLog" } + if (Test-Path $backendLog) { Write-Host "backend_log=$backendLog" } + if (Test-Path $channelWecomLog) { Write-Host "channel_wecom_log=$channelWecomLog" } exit 0 } catch { Warn "desktop process not found: PID=$procId" diff --git a/runtime/operations/scripts/weixin_install.ps1 b/runtime/operations/scripts/weixin_install.ps1 index 61d62303..64ed1d26 100644 --- a/runtime/operations/scripts/weixin_install.ps1 +++ b/runtime/operations/scripts/weixin_install.ps1 @@ -25,7 +25,6 @@ function Sync-WeixinBridgeRunners { } New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null -New-Item -ItemType Directory -Force -Path (Join-Path $sidecarRoot "logs") | Out-Null New-Item -ItemType Directory -Force -Path $stateDir | Out-Null function Ensure-OfficialPluginRuntimeDeps { diff --git a/runtime/operations/scripts/weixin_start.ps1 b/runtime/operations/scripts/weixin_start.ps1 index 9235a0f1..72f354d2 100644 --- a/runtime/operations/scripts/weixin_start.ps1 +++ b/runtime/operations/scripts/weixin_start.ps1 @@ -14,11 +14,14 @@ function Resolve-RepoRoot { $oclawRoot = Resolve-RepoRoot $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" $stateDir = Join-Path $sidecarRoot "state" -$logDir = Join-Path $sidecarRoot "logs" $pidFile = Join-Path $sidecarRoot "pid.txt" $bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\weixin_bridge" $pluginRoot = Join-Path $env:USERPROFILE ".openclaw\\extensions\\openclaw-weixin" +$env:PYTHONPATH = $oclawRoot +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot + function Get-SidecarProcesses { $escapedSidecarRoot = $sidecarRoot.Replace("\", "\\") $patterns = @( @@ -74,8 +77,8 @@ function Ensure-OfficialPluginRuntimeDeps { if (-not (Test-Path $sidecarRoot)) { New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null } -New-Item -ItemType Directory -Force -Path $logDir | Out-Null New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null $cleaned = Stop-SidecarProcesses Remove-Item -Force $pidFile -ErrorAction SilentlyContinue @@ -89,8 +92,8 @@ if (Test-Path $bridgeSrc) { } } -$logPath = Join-Path $logDir "weixin_sidecar.log" -$errPath = Join-Path $logDir "weixin_sidecar.err.log" +$logPath = Join-Path $runtimeLogDir "weixin_sidecar.log" +$errPath = Join-Path $runtimeLogDir "weixin_sidecar.err.log" if (Test-Path (Join-Path $sidecarRoot "official_runner.ts")) { Ensure-OfficialPluginRuntimeDeps $cmd = "cmd.exe" diff --git a/runtime/operations/scripts/whatsapp_install.ps1 b/runtime/operations/scripts/whatsapp_install.ps1 index daeadcba..6586b614 100644 --- a/runtime/operations/scripts/whatsapp_install.ps1 +++ b/runtime/operations/scripts/whatsapp_install.ps1 @@ -17,7 +17,6 @@ function Resolve-RepoRoot { $oclawRoot = Resolve-RepoRoot $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" $stateDir = Join-Path $sidecarRoot "state" -$logDir = Join-Path $sidecarRoot "logs" $bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge" # Pin known-good versions to avoid breakage from upstream latest releases. @@ -29,7 +28,6 @@ $typescriptVersion = "5.8.2" New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null New-Item -ItemType Directory -Force -Path $stateDir | Out-Null -New-Item -ItemType Directory -Force -Path $logDir | Out-Null Push-Location $sidecarRoot try { diff --git a/runtime/operations/scripts/whatsapp_start.ps1 b/runtime/operations/scripts/whatsapp_start.ps1 index 5828e282..17370812 100644 --- a/runtime/operations/scripts/whatsapp_start.ps1 +++ b/runtime/operations/scripts/whatsapp_start.ps1 @@ -14,15 +14,18 @@ function Resolve-RepoRoot { $oclawRoot = Resolve-RepoRoot $sidecarRoot = Join-Path $oclawRoot "data\\channel_sidecar\\$ChannelId" $stateDir = Join-Path $sidecarRoot "state" -$logDir = Join-Path $sidecarRoot "logs" $sidecarPidFile = Join-Path $sidecarRoot "pid.txt" +$env:PYTHONPATH = $oclawRoot +. (Join-Path $PSScriptRoot "lib\ResolveRuntimeLogDir.ps1") +$runtimeLogDir = Get-OclawRuntimeLogDir -RepoRoot $oclawRoot + New-Item -ItemType Directory -Force -Path $sidecarRoot | Out-Null New-Item -ItemType Directory -Force -Path $stateDir | Out-Null -New-Item -ItemType Directory -Force -Path $logDir | Out-Null +New-Item -ItemType Directory -Force -Path $runtimeLogDir | Out-Null -$logPath = Join-Path $logDir "whatsapp_sidecar.log" -$errPath = Join-Path $logDir "whatsapp_sidecar.err.log" +$logPath = Join-Path $runtimeLogDir "whatsapp_sidecar.log" +$errPath = Join-Path $runtimeLogDir "whatsapp_sidecar.err.log" $systemNodeDir = "C:\\Program Files\\nodejs" if (Test-Path (Join-Path $systemNodeDir "node.exe")) { $env:PATH = "$systemNodeDir;$env:PATH" diff --git a/runtime/workers/wiki/main.py b/runtime/workers/wiki/main.py index ea1aa315..21e2fba8 100644 --- a/runtime/workers/wiki/main.py +++ b/runtime/workers/wiki/main.py @@ -337,4 +337,9 @@ def run_worker() -> int: if __name__ == "__main__": + from svc.config.bootstrap_env import load_system_env + from svc.observability.logging_setup import configure_oclaw_logging + + load_system_env() + configure_oclaw_logging(service_name="wiki-worker", include_uvicorn_formatters=False) raise SystemExit(run_worker()) diff --git a/svc/config/log_paths.py b/svc/config/log_paths.py new file mode 100644 index 00000000..4cb707ac --- /dev/null +++ b/svc/config/log_paths.py @@ -0,0 +1,40 @@ +"""Runtime log root (gateway / channel workers / stack scripts). + +Matches ``AIA_RUNTIME_LOG_DIR`` or ``/logs`` — same rule as +``runtime.operations.runtime.assistant_runtime_log_dir`` but lives under ``svc.config`` to avoid +import cycles when HTTP or workers configure logging early. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from svc.config.paths import db_path + + +def oclaw_log_root() -> Path: + p = str(os.getenv("AIA_RUNTIME_LOG_DIR") or "").strip() + if p: + return Path(p).expanduser().resolve() + return Path(db_path()).resolve().parent / "logs" + + +def oclaw_hooks_log_dir(*, state_dir_if_legacy: Path) -> Path: + """Directory for bundled hook file logs (``command-logger``, ``boot-md``). + + Default: ``oclaw_log_root() / "hooks"`` (same tree as gateway/channel ``start_service`` logs). + + Set ``OCLAW_HOOK_LOG_USE_STATE_DIR=1`` to restore the legacy layout + ``state_dir_if_legacy / "logs"`` (typically ``~/.oclaw/logs``). + """ + raw = str(os.getenv("OCLAW_HOOK_LOG_USE_STATE_DIR") or "").strip().lower() + if raw in ("1", "true", "yes", "on"): + out = state_dir_if_legacy / "logs" + else: + out = oclaw_log_root() / "hooks" + out.mkdir(parents=True, exist_ok=True) + return out + + +__all__ = ["oclaw_hooks_log_dir", "oclaw_log_root"] diff --git a/svc/observability/__init__.py b/svc/observability/__init__.py new file mode 100644 index 00000000..00755725 --- /dev/null +++ b/svc/observability/__init__.py @@ -0,0 +1,9 @@ +"""Observability helpers (logging, metrics hooks).""" + +from svc.observability.logging_setup import ( + build_uvicorn_logging_dict_config, + configure_oclaw_logging, + skip_file_logging, +) + +__all__ = ["build_uvicorn_logging_dict_config", "configure_oclaw_logging", "skip_file_logging"] diff --git a/svc/observability/logging_setup.py b/svc/observability/logging_setup.py new file mode 100644 index 00000000..ea9b061e --- /dev/null +++ b/svc/observability/logging_setup.py @@ -0,0 +1,193 @@ +"""Central logging setup: rotating files under :func:`svc.config.log_paths.oclaw_log_root`.""" + +from __future__ import annotations + +import logging.config +import os +from pathlib import Path +from typing import Any + +from svc.config.log_paths import oclaw_log_root + +_CONFIGURED = False + + +def _skip_file_handlers() -> bool: + if str(os.getenv("AIA_LOG_TO_FILE") or "").strip().lower() in ("0", "false", "no", "off"): + return True + if os.environ.get("PYTEST_CURRENT_TEST"): + return True + return False + + +def skip_file_logging() -> bool: + """Whether rotating file handlers are disabled (pytest or ``AIA_LOG_TO_FILE=0``).""" + return _skip_file_handlers() + + +def _resolve_level_name() -> str: + raw = (os.environ.get("OCLAW_LOG_LEVEL") or os.environ.get("AIA_LOG_LEVEL") or "INFO").strip().upper() + if raw not in ("DEBUG", "INFO", "WARNING", "WARN", "ERROR", "CRITICAL", "FATAL"): + return "INFO" + if raw == "WARN": + return "WARNING" + if raw == "FATAL": + return "CRITICAL" + return raw + + +def _int_env(name: str, default: int) -> int: + raw = str(os.getenv(name) or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +def build_worker_logging_dict_config( + *, + log_root: Path | None = None, + level: str | None = None, + max_bytes: int | None = None, + backup_count: int | None = None, +) -> dict[str, Any]: + """dictConfig for non-uvicorn workers (plain :mod:`logging` formatters).""" + root = log_root or oclaw_log_root() + lvl = level or _resolve_level_name() + mb = int(max_bytes if max_bytes is not None else _int_env("OCLAW_LOG_MAX_BYTES", 20_971_520)) + bc = int(backup_count if backup_count is not None else _int_env("OCLAW_LOG_BACKUP_COUNT", 5)) + app_dir = root / "app" + app_dir.mkdir(parents=True, exist_ok=True) + oclaw_path = app_dir / "oclaw.log" + fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s" + datefmt = "%Y-%m-%d %H:%M:%S" + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "file": { + "format": fmt, + "datefmt": datefmt, + }, + }, + "handlers": { + "oclaw_file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "file", + "filename": str(oclaw_path), + "maxBytes": max(256_000, mb), + "backupCount": max(0, min(bc, 100)), + "encoding": "utf-8", + }, + }, + "root": {"handlers": ["oclaw_file"], "level": lvl}, + } + + +def build_uvicorn_logging_dict_config( + *, + log_root: Path | None = None, + level: str | None = None, + max_bytes: int | None = None, + backup_count: int | None = None, +) -> dict[str, Any]: + """dictConfig compatible with ``uvicorn.run(log_config=...)`` (uses uvicorn formatters).""" + root = log_root or oclaw_log_root() + lvl = level or _resolve_level_name() + mb = int(max_bytes if max_bytes is not None else _int_env("OCLAW_LOG_MAX_BYTES", 20_971_520)) + bc = int(backup_count if backup_count is not None else _int_env("OCLAW_LOG_BACKUP_COUNT", 5)) + app_dir = root / "app" + app_dir.mkdir(parents=True, exist_ok=True) + oclaw_path = app_dir / "oclaw.log" + access_path = app_dir / "uvicorn-access.log" + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(message)s", + "use_colors": False, + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s', + }, + }, + "handlers": { + "default_file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "default", + "filename": str(oclaw_path), + "maxBytes": max(256_000, mb), + "backupCount": max(0, min(bc, 100)), + "encoding": "utf-8", + }, + "access_file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "access", + "filename": str(access_path), + "maxBytes": max(256_000, mb), + "backupCount": max(0, min(bc, 100)), + "encoding": "utf-8", + }, + }, + "loggers": { + "uvicorn": {"handlers": ["default_file"], "level": lvl, "propagate": False}, + "uvicorn.error": {"handlers": ["default_file"], "level": lvl, "propagate": False}, + "uvicorn.access": {"handlers": ["access_file"], "level": lvl, "propagate": False}, + }, + "root": {"handlers": ["default_file"], "level": lvl}, + } + + +def configure_oclaw_logging( + *, + service_name: str | None = None, + include_uvicorn_formatters: bool = False, + _force_file_handlers: bool = False, +) -> None: + """Prepare or apply rotating file logging under the runtime log root (idempotent). + + Skips file handlers during pytest (``PYTEST_CURRENT_TEST``) or when + ``AIA_LOG_TO_FILE=0`` (false/no/off), unless ``_force_file_handlers`` is true (tests only). + + When ``include_uvicorn_formatters`` is true, only creates ``log_root`` / ``app``; the caller + must pass :func:`build_uvicorn_logging_dict_config` to ``uvicorn.run(log_config=...)`` so + ``dictConfig`` runs once (avoids duplicate handlers). + + ``service_name`` is reserved for future structured fields; process/service is still visible + in logger names and file layout (``app/oclaw.log`` per process). + """ + global _CONFIGURED + _ = service_name + if _CONFIGURED: + return + if _skip_file_handlers() and not _force_file_handlers: + _CONFIGURED = True + return + log_root = oclaw_log_root() + log_root.mkdir(parents=True, exist_ok=True) + (log_root / "app").mkdir(parents=True, exist_ok=True) + if include_uvicorn_formatters: + _CONFIGURED = True + return + logging.config.dictConfig(build_worker_logging_dict_config(log_root=log_root)) + _CONFIGURED = True + + +def reset_oclaw_logging_for_tests() -> None: + """Clear idempotency flag (tests only).""" + global _CONFIGURED + _CONFIGURED = False + + +__all__ = [ + "build_uvicorn_logging_dict_config", + "build_worker_logging_dict_config", + "configure_oclaw_logging", + "reset_oclaw_logging_for_tests", + "skip_file_logging", +] diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py new file mode 100644 index 00000000..346e19ba --- /dev/null +++ b/tests/test_logging_setup.py @@ -0,0 +1,91 @@ +"""Tests for :mod:`svc.observability.logging_setup` and :mod:`svc.config.log_paths`.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from svc.config import log_paths +from svc.observability import logging_setup + + +def test_oclaw_log_root_respects_aia_runtime_log_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "xlogs")) + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + assert log_paths.oclaw_log_root() == (tmp_path / "xlogs").resolve() + + +def test_configure_worker_writes_rotating_log( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_root = tmp_path / "logs" + monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(log_root)) + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + logging_setup.reset_oclaw_logging_for_tests() + + logging_setup.configure_oclaw_logging( + service_name="test", + include_uvicorn_formatters=False, + _force_file_handlers=True, + ) + log = logging.getLogger("test_logging_module") + log.warning("hello from logging setup test") + + app_log = log_root / "app" / "oclaw.log" + assert app_log.is_file() + text = app_log.read_text(encoding="utf-8", errors="replace") + assert "hello from logging setup test" in text + + logging_setup.configure_oclaw_logging( + service_name="test", + include_uvicorn_formatters=False, + _force_file_handlers=True, + ) + handlers = [h for h in logging.root.handlers if hasattr(h, "baseFilename")] + assert len(handlers) == 1 + + +def test_configure_uvicorn_deferred_marks_configured_without_dictconfig( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_root = tmp_path / "logs2" + monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(log_root)) + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + logging_setup.reset_oclaw_logging_for_tests() + + logging_setup.configure_oclaw_logging( + service_name="gw", + include_uvicorn_formatters=True, + _force_file_handlers=True, + ) + assert (log_root / "app").is_dir() + assert not (log_root / "app" / "oclaw.log").exists() + + +def test_oclaw_hooks_log_dir_defaults_under_runtime_root( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "logs")) + monkeypatch.delenv("OCLAW_HOOK_LOG_USE_STATE_DIR", raising=False) + state = tmp_path / "fake_state" + state.mkdir() + d = log_paths.oclaw_hooks_log_dir(state_dir_if_legacy=state) + assert d == (tmp_path / "logs" / "hooks").resolve() + assert d.is_dir() + + +def test_oclaw_hooks_log_dir_legacy_when_env_set( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AIA_RUNTIME_LOG_DIR", str(tmp_path / "logs")) + monkeypatch.setenv("OCLAW_HOOK_LOG_USE_STATE_DIR", "1") + state = tmp_path / "fake_state" + state.mkdir() + d = log_paths.oclaw_hooks_log_dir(state_dir_if_legacy=state) + assert d == (state / "logs").resolve()