# AM62x log_node 遠端日誌存取實施計劃

## Overview

在 AM62x 開發環境下，FWAuto 在 VM 中執行，而韌體日誌透過 UART 輸出到開發機（Arch Linux）。本計劃旨在擴展 `log_node` 功能，支援透過 SSH/SCP 存取開發機上的遠端日誌檔案，同時保持向後相容性和 KISS 原則。

**實施方案**：在 `log_node` 內部實作 SSH/SCP 遠端存取，使用 Python `subprocess` + 系統 `scp` 命令（零新依賴）。

## Current State Analysis

### 現有 log_node 架構

**檔案位置**：`src/fwauto/nodes/log.py`

**當前流程**：

```mermaid
graph LR
    A["輸入路徑"] --> B["相對轉絕對<br/>L50-53"]
    B --> C["驗證存在<br/>L56-61"]
    C --> D["提取目錄<br/>L89"]
    D --> E["Claude SDK<br/>L103-108"]
    E --> F["AI 分析"]
```

**關鍵限制**：
- 使用 `os.path.exists()` 只適用本地檔案系統（L56）
- `add_dirs` 參數要求目錄在本地可存取（L106）
- 無任何遠端協議處理

### AM62x 環境架構

```mermaid
graph TB
    subgraph DEV["開發機 (Arch Linux)"]
        UART["UART 連接<br/>/dev/ttyACM0/1"]
        LOG_FILE["日誌檔案<br/>/home/kent/uart.log"]
    end

    subgraph VM["VM (VirtualBox)"]
        FWAUTO["FWAuto"]
        LOG_NODE["log_node"]
    end

    subgraph BOARD["開發板 (AM62X)"]
        APP["應用程式"]
    end

    BOARD -->|UART| UART
    UART --> LOG_FILE
    VM -.->|❌ 無法直接存取| LOG_FILE
    LOG_NODE -->|✅ 需實作 SSH/SCP| LOG_FILE

    style DEV fill:#e1f5ff
    style VM fill:#fff3e0
    style BOARD fill:#f3e5f5
```

**網路配置**：
- 開發機：192.168.50.x (Host)
- VM：192.168.50.163
- 開發板：192.168.50.170

### 既有 SSH/SCP 基礎

**參考實現**：`src/fwauto/templates/sdk_am62x_linux/flash/flash.sh`

```bash
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5"
scp $SSH_OPTS "$binary" "$BOARD_USER@$BOARD_IP:$remote_path"
ssh $SSH_OPTS "$BOARD_USER@$BOARD_IP" "command"
```

**關鍵優勢**：
- ✅ 已在生產環境驗證
- ✅ 零新依賴（使用系統 ssh/scp）
- ✅ 符合 KISS 原則

## Desired End State

### 功能目標

```mermaid
graph LR
    A["使用者執行"] --> B{"路徑類型?"}
    B -->|本地| C["直接分析<br/>(現有邏輯)"]
    B -->|遠端 CLI| D["SCP 下載<br/>user@host:path"]
    B -->|遠端配置| E["SCP 下載<br/>(使用配置)"]
    D --> F["暫存到<br/>.fwauto/logs/"]
    E --> F
    F --> G["AI 分析"]
    G --> H["清理暫存"]
    C --> G
```

### 使用場景

**場景 1：CLI 直接指定遠端路徑**
```bash
fwauto log "有錯誤嗎?" --log-path kent@192.168.50.1:/home/kent/uart.log
```

**場景 2：配置檔簡寫**
```toml
# .fwauto/config.toml
[log]
remote_host = "192.168.50.1"
remote_user = "kent"
ssh_options = "-o ConnectTimeout=10"  # 可選
```
```bash
fwauto log "有錯誤嗎?" --log-path /home/kent/uart.log  # 自動使用配置
```

**場景 3：本地檔案（向後相容）**
```bash
fwauto log "有錯誤嗎?" --log-path logs/uart.log  # 現有功能繼續運作
```

### 成功驗證

- ✅ CLI 遠端路徑正常運作
- ✅ 配置檔簡寫正常運作
- ✅ 本地路徑功能保持不變
- ✅ 錯誤訊息清楚易懂
- ✅ 暫存檔案自動清理
- ✅ 測試覆蓋率 > 80%

## What We're NOT Doing

明確排除以下功能，避免範圍蔓延：

❌ **不支援其他協議**：
- 不支援 SFTP、FTP、HTTP
- 只支援 SCP（SSH 檔案傳輸）

❌ **不自動管理 SSH 金鑰**：
- 不實作 `fwauto init-ssh` 設定助手
- 使用者需自行設定無密碼登入

❌ **不支援即時日誌串流**：
- 只支援檔案分析
- 不支援 `ssh tail -f` 即時串流

❌ **不修改 FirmwareState**：
- 不新增狀態欄位
- 使用現有 `log_file_path` 和 `log_analysis_result`

❌ **不新增 Python 依賴**：
- 不使用 Paramiko、Fabric 等 SSH 套件
- 使用 Python 標準庫 + 系統命令

## Implementation Approach

### 核心策略

1. **漸進式實作**：分 3 個階段，每階段可獨立測試
2. **內部模組化**：在 `log_node` 內部新增輔助函數
3. **向後相容**：本地路徑邏輯優先，遠端作為擴展
4. **早期失敗**：路徑解析、SCP 下載失敗立即返回錯誤
5. **參考 flash.sh**：SSH 選項、錯誤處理模式保持一致

### 架構設計

```mermaid
graph TB
    subgraph log_node["log_node (擴展後)"]
        MAIN["log_node()<br/>主函數"]
        RESOLVE["_resolve_log_path()<br/>路徑解析"]
        IS_REMOTE["_is_remote_path()<br/>遠端偵測"]
        PARSE["_parse_remote_path()<br/>解析 user@host:path"]
        FETCH["_fetch_remote_log()<br/>SCP 下載"]
        LOCAL["_resolve_local_path()<br/>本地處理"]
        CLEANUP["_cleanup_temp_files()<br/>清理暫存"]
    end

    MAIN --> RESOLVE
    RESOLVE --> IS_REMOTE
    IS_REMOTE -->|遠端| PARSE
    IS_REMOTE -->|本地| LOCAL
    PARSE --> FETCH
    FETCH --> MAIN
    LOCAL --> MAIN
    MAIN --> CLEANUP
```

### 技術選型

| 面向 | 技術選擇 | 理由 |
|------|---------|------|
| **SSH/SCP** | `subprocess.run(["scp", ...])` | 零依賴，與 flash.sh 一致 |
| **路徑解析** | 正則表達式 `user@host:path` | 簡單可靠 |
| **暫存目錄** | `.fwauto/logs/` | 專案內部，易於管理 |
| **錯誤處理** | `subprocess.CalledProcessError` | 標準異常，詳細訊息 |
| **配置讀取** | `ProjectConfig` | 現有系統，無需新增 |

## Phase 1: 核心遠端檔案下載功能

### Overview

實作基本的遠端日誌下載功能，支援 CLI 直接指定 `user@host:path` 格式。

### 實施流程

```mermaid
graph TD
    START["Phase 1 開始"] --> A["新增輔助函數"]
    A --> B["擴展 log_node 主邏輯"]
    B --> C["建立暫存目錄管理"]
    C --> D["實作錯誤處理"]
    D --> E["向後相容測試"]
    E --> END["Phase 1 完成"]

    style START fill:#90EE90
    style END fill:#90EE90
```

### Changes Required

#### 1. 新增遠端路徑偵測函數

- [x] 完成

**檔案**：`src/fwauto/nodes/log.py`

**位置**：在 `log_node()` 函數之後新增

```python
def _is_remote_path(path: str) -> bool:
    """
    檢查路徑是否為遠端格式 (user@host:path)

    Args:
        path: 日誌檔案路徑

    Returns:
        True if remote path, False otherwise

    Examples:
        >>> _is_remote_path("kent@192.168.50.1:/home/kent/uart.log")
        True
        >>> _is_remote_path("logs/uart.log")
        False
        >>> _is_remote_path("/absolute/path/uart.log")
        False
    """
    import re
    # 匹配格式: user@host:path
    pattern = r'^[^@]+@[^:]+:.+'
    return bool(re.match(pattern, path))
```

#### 2. 新增遠端路徑解析函數

- [x] 完成

**檔案**：`src/fwauto/nodes/log.py`

```python
def _parse_remote_path(remote_path: str) -> tuple[str, str, str]:
    """
    解析遠端路徑格式

    Args:
        remote_path: user@host:path 格式

    Returns:
        Tuple of (user, host, path)

    Raises:
        ValueError: 如果格式不正確

    Examples:
        >>> _parse_remote_path("kent@192.168.50.1:/home/kent/uart.log")
        ('kent', '192.168.50.1', '/home/kent/uart.log')
    """
    import re

    # 匹配並提取: user@host:path
    match = re.match(r'^([^@]+)@([^:]+):(.+)$', remote_path)
    if not match:
        raise ValueError(f"Invalid remote path format: {remote_path}")

    user, host, path = match.groups()
    return user, host, path
```

#### 3. 新增 SCP 下載函數

- [x] 完成

**檔案**：`src/fwauto/nodes/log.py`

**新增 import**：
```python
import subprocess
from pathlib import Path
```

**函數實作**：
```python
def _fetch_remote_log(
    remote_path: str,
    temp_dir: Path,
    ssh_options: str | None = None
) -> str:
    """
    使用 SCP 下載遠端日誌到暫存目錄

    Args:
        remote_path: user@host:path 格式
        temp_dir: 暫存目錄路徑
        ssh_options: SSH 選項字串（可選）

    Returns:
        本地暫存檔案路徑

    Raises:
        RuntimeError: SCP 下載失敗
    """
    try:
        user, host, path = _parse_remote_path(remote_path)
    except ValueError as e:
        raise RuntimeError(f"Failed to parse remote path: {e}")

    # 預設 SSH 選項（參考 flash.sh）
    default_ssh_opts = [
        "-o", "StrictHostKeyChecking=no",
        "-o", "UserKnownHostsFile=/dev/null",
        "-o", "ConnectTimeout=5"
    ]

    # 如果有自訂選項，解析並覆蓋
    if ssh_options:
        # 簡單解析：用空格分割（未來可改進）
        custom_opts = ssh_options.split()
        ssh_opts = custom_opts
    else:
        ssh_opts = default_ssh_opts

    # 建立暫存檔案路徑
    import hashlib
    # 使用遠端路徑的 hash 作為檔名，避免衝突
    filename = hashlib.md5(remote_path.encode()).hexdigest() + ".log"
    local_path = temp_dir / filename

    # 執行 SCP 命令
    scp_cmd = ["scp"] + ssh_opts + [f"{user}@{host}:{path}", str(local_path)]

    logger.info(f"📥 Downloading remote log: {remote_path}")
    logger.debug(f"SCP command: {' '.join(scp_cmd)}")

    try:
        result = subprocess.run(
            scp_cmd,
            capture_output=True,
            text=True,
            check=True,
            timeout=30  # 30 秒超時
        )
        logger.info(f"✅ Download successful: {local_path}")
        return str(local_path)

    except subprocess.TimeoutExpired:
        raise RuntimeError(
            f"SCP timeout: Remote host {host} did not respond within 30 seconds"
        )
    except subprocess.CalledProcessError as e:
        # 詳細錯誤分析
        stderr = e.stderr.lower()

        if "connection refused" in stderr or "no route to host" in stderr:
            raise RuntimeError(
                f"Cannot connect to remote host {host}. "
                f"Check network connectivity and firewall settings."
            )
        elif "permission denied" in stderr or "publickey" in stderr:
            raise RuntimeError(
                f"SSH authentication failed for {user}@{host}. "
                f"Check SSH keys and permissions."
            )
        elif "no such file" in stderr or "not found" in stderr:
            raise RuntimeError(
                f"Remote log file not found: {path} on {host}"
            )
        else:
            raise RuntimeError(
                f"SCP download failed: {e.stderr}"
            )
    except FileNotFoundError:
        raise RuntimeError(
            "SCP command not found. Please install OpenSSH client."
        )
```

#### 4. 新增暫存目錄管理函數

- [x] 完成

**檔案**：`src/fwauto/nodes/log.py`

```python
def _get_temp_log_dir(project_root: str) -> Path:
    """
    取得或建立暫存日誌目錄

    Args:
        project_root: 專案根目錄

    Returns:
        暫存目錄路徑 (.fwauto/logs/)
    """
    temp_dir = Path(project_root) / ".fwauto" / "logs"
    temp_dir.mkdir(parents=True, exist_ok=True)
    return temp_dir


def _cleanup_temp_files(file_path: str, is_temp: bool) -> None:
    """
    清理暫存日誌檔案

    Args:
        file_path: 檔案路徑
        is_temp: 是否為暫存檔案（遠端下載的）
    """
    if not is_temp:
        return

    try:
        Path(file_path).unlink(missing_ok=True)
        logger.debug(f"🗑️  Cleaned up temp file: {file_path}")
    except Exception as e:
        logger.warning(f"⚠️  Failed to clean up temp file {file_path}: {e}")
```

#### 5. 擴展 log_node 主邏輯

- [x] 完成

**檔案**：`src/fwauto/nodes/log.py`

**修改位置**：`log_node()` 函數內部（L50-61）

**原始邏輯**：
```python
# Convert relative paths to absolute
if not os.path.isabs(log_file_path):
    log_file_path = os.path.join(os.getcwd(), log_file_path)
    logger.info(f"📊 Converted to absolute path: {log_file_path}")

# Validate log file exists
if not os.path.exists(log_file_path):
    logger.error(f"❌ Log Analysis: Log file not found: {log_file_path}")
    return {
        **state,
        "log_analysis_result": f"Error: Log file not found: {log_file_path}",
    }
```

**新邏輯**：
```python
# 路徑解析：支援本地和遠端
is_temp_file = False  # 追蹤是否需要清理

try:
    if _is_remote_path(log_file_path):
        # 遠端路徑：下載到暫存目錄
        logger.info(f"📡 Detected remote path: {log_file_path}")

        project_root = state.get("project_root", os.getcwd())
        temp_dir = _get_temp_log_dir(project_root)

        # Phase 1: 暫不支援 ssh_options（Phase 2 才從配置讀取）
        log_file_path = _fetch_remote_log(log_file_path, temp_dir, ssh_options=None)
        is_temp_file = True

    else:
        # 本地路徑：現有邏輯
        if not os.path.isabs(log_file_path):
            log_file_path = os.path.join(os.getcwd(), log_file_path)
            logger.info(f"📊 Converted to absolute path: {log_file_path}")

        if not os.path.exists(log_file_path):
            logger.error(f"❌ Log Analysis: Log file not found: {log_file_path}")
            return {
                **state,
                "log_analysis_result": f"Error: Log file not found: {log_file_path}",
            }

except RuntimeError as e:
    # 遠端下載錯誤
    logger.error(f"❌ Remote log download failed: {e}")
    return {
        **state,
        "log_analysis_result": f"Error: {e}",
    }
```

**在函數結尾新增清理**（L119 之後）：
```python
# Execute async analysis
try:
    result = asyncio.run(_run_analysis(prompt, options))
    logger.info("✅ Log Analysis: Completed successfully")
    return {
        **state,
        "log_analysis_result": result,
    }
except Exception as e:
    logger.error(f"❌ Log Analysis: Analysis failed: {e}")
    return {
        **state,
        "log_analysis_result": f"Error: Analysis failed: {e}",
    }
finally:
    # 清理暫存檔案
    _cleanup_temp_files(log_file_path, is_temp_file)
```

### Success Criteria

#### Automated Verification

- [x] 單元測試通過：`pytest tests/test_story_004_log_analysis_node.py -v`
- [ ] 新增測試通過：`pytest tests/test_remote_log_access.py -v` (Phase 1 測試，待 Phase 3 實作)
- [x] Linting 通過：`uv run python -m fwauto.scripts.lint`
- [x] Type checking 通過：`uv run pyright src/fwauto/nodes/log.py`
- [x] 本地路徑回歸測試通過

#### Manual Verification

- [ ] CLI 遠端路徑功能正常：`fwauto log "測試" --log-path user@host:/path/log`
- [ ] 本地路徑功能保持不變：`fwauto log "測試" --log-path logs/uart.log`
- [ ] 錯誤訊息清楚易懂：
  - [ ] 連線失敗錯誤訊息明確
  - [ ] 權限錯誤訊息明確
  - [ ] 檔案不存在錯誤訊息明確
- [ ] 暫存檔案自動建立在 `.fwauto/logs/`
- [ ] 分析完成後暫存檔案被清理

**Implementation Note**: 完成此階段並通過所有自動化測試後，暫停並手動測試上述場景，確認無誤後再繼續 Phase 2。

---

## Phase 2: 配置系統整合

### Overview

擴展配置系統，支援配置檔簡寫和自訂 SSH 選項。

### 實施流程

```mermaid
graph TD
    START["Phase 2 開始"] --> A["擴展 ProjectConfig"]
    A --> B["實作配置讀取邏輯"]
    B --> C["整合到 log_node"]
    C --> D["更新 CLI 幫助訊息"]
    D --> END["Phase 2 完成"]

    style START fill:#90EE90
    style END fill:#90EE90
```

### Changes Required

#### 1. 擴展 ProjectConfig

**檔案**：`src/fwauto/config.py`

**位置**：`ProjectConfig` 類別內部

**新增屬性**：
```python
@property
def log_remote_host(self) -> str | None:
    """Get remote host for log files."""
    return self.config.get("log", {}).get("remote_host")

@property
def log_remote_user(self) -> str | None:
    """Get remote user for log files."""
    return self.config.get("log", {}).get("remote_user")

@property
def log_remote_path(self) -> str | None:
    """Get default remote log path."""
    return self.config.get("log", {}).get("remote_path")

@property
def log_ssh_options(self) -> str | None:
    """Get custom SSH options for log file transfer."""
    return self.config.get("log", {}).get("ssh_options")
```

**更新 `_get_default_config()`**：
```python
def _get_default_config(self) -> dict:
    """Get default configuration."""
    return {
        "project": {
            "name": self.project_root.name,
        },
        "build": {
            "makefile": str(self.build_makefile_path),
        },
        "flash": {
            "makefile": str(self.flash_makefile_path),
        },
        # 新增 log section 預設值
        "log": {
            "remote_host": None,
            "remote_user": None,
            "remote_path": None,
            "ssh_options": None,  # 使用預設值
        },
    }
```

#### 2. 新增配置檔路徑解析函數

**檔案**：`src/fwauto/nodes/log.py`

**新增 import**：
```python
from ..config import ProjectConfig
```

**新增函數**：
```python
def _resolve_config_path(
    path: str,
    project_root: str
) -> tuple[str, dict[str, str | None]]:
    """
    解析路徑並從配置檔取得遠端資訊

    Args:
        path: 使用者提供的路徑
        project_root: 專案根目錄

    Returns:
        Tuple of (resolved_path, remote_config)
        - resolved_path: 解析後的完整路徑 (user@host:path 或本地路徑)
        - remote_config: {"host": ..., "user": ..., "ssh_options": ...} 或空 dict

    Examples:
        # 已有 user@host:path，直接返回
        >>> _resolve_config_path("kent@192.168.50.1:/path", "/project")
        ("kent@192.168.50.1:/path", {})

        # 配置檔設定 remote_host/user，組合完整路徑
        >>> _resolve_config_path("/home/kent/uart.log", "/project")
        ("kent@192.168.50.1:/home/kent/uart.log", {"ssh_options": "-o ConnectTimeout=10"})

        # 無遠端配置，返回原路徑
        >>> _resolve_config_path("logs/uart.log", "/project")
        ("logs/uart.log", {})
    """
    # 如果已經是遠端格式，直接返回
    if _is_remote_path(path):
        return path, {}

    # 嘗試從配置讀取遠端資訊
    try:
        config = ProjectConfig(Path(project_root))

        remote_host = config.log_remote_host
        remote_user = config.log_remote_user
        ssh_options = config.log_ssh_options

        # 如果有配置遠端 host 和 user，組合完整路徑
        if remote_host and remote_user:
            # 確保路徑是絕對路徑
            if not path.startswith("/"):
                # 如果配置有 remote_path，使用它作為基準
                if config.log_remote_path:
                    path = str(Path(config.log_remote_path) / path)
                else:
                    # 否則假設是絕對路徑
                    logger.warning(
                        f"⚠️  Relative path '{path}' with remote config. "
                        f"Consider setting log.remote_path in config."
                    )

            resolved = f"{remote_user}@{remote_host}:{path}"
            logger.info(f"📋 Resolved from config: {resolved}")

            return resolved, {"ssh_options": ssh_options}

    except Exception as e:
        logger.debug(f"No remote config found: {e}")

    # 無遠端配置，返回原路徑
    return path, {}
```

#### 3. 整合配置到 log_node

**檔案**：`src/fwauto/nodes/log.py`

**修改位置**：`log_node()` 函數內部路徑解析邏輯

**替換 Phase 1 的邏輯**：
```python
# 路徑解析：支援本地、遠端 CLI、遠端配置
is_temp_file = False
ssh_options = None  # SSH 選項

try:
    # Step 1: 配置檔路徑解析（可能將本地路徑轉為遠端）
    project_root = state.get("project_root", os.getcwd())
    log_file_path, remote_config = _resolve_config_path(log_file_path, project_root)

    # Step 2: 檢查是否為遠端路徑
    if _is_remote_path(log_file_path):
        logger.info(f"📡 Detected remote path: {log_file_path}")

        # 使用配置的 SSH 選項（如果有）
        ssh_options = remote_config.get("ssh_options")

        temp_dir = _get_temp_log_dir(project_root)
        log_file_path = _fetch_remote_log(log_file_path, temp_dir, ssh_options)
        is_temp_file = True

    else:
        # 本地路徑：現有邏輯
        if not os.path.isabs(log_file_path):
            log_file_path = os.path.join(os.getcwd(), log_file_path)
            logger.info(f"📊 Converted to absolute path: {log_file_path}")

        if not os.path.exists(log_file_path):
            logger.error(f"❌ Log Analysis: Log file not found: {log_file_path}")
            return {
                **state,
                "log_analysis_result": f"Error: Log file not found: {log_file_path}",
            }

except RuntimeError as e:
    logger.error(f"❌ Remote log access failed: {e}")
    return {
        **state,
        "log_analysis_result": f"Error: {e}",
    }
```

#### 4. 更新文檔和範例配置

**檔案**：`docs/am62x.md`（新增或更新）

**新增 log 配置範例**：
```markdown
## 日誌配置

### 遠端日誌存取

在 AM62x 環境下，日誌通常在開發機上。可透過配置檔簡化存取：

**配置檔**：`.fwauto/config.toml`
```toml
[log]
# 遠端主機和使用者
remote_host = "192.168.50.1"  # 開發機 IP
remote_user = "kent"

# 預設遠端日誌路徑（可選）
remote_path = "/home/kent/logs"

# 自訂 SSH 選項（可選）
ssh_options = "-o ConnectTimeout=10"
```

**使用方式**：
```bash
# 配置後可直接使用相對路徑
fwauto log "有錯誤嗎?" --log-path uart.log
# 自動解析為: kent@192.168.50.1:/home/kent/logs/uart.log

# 或使用絕對路徑
fwauto log "有錯誤嗎?" --log-path /home/kent/uart.log
# 自動解析為: kent@192.168.50.1:/home/kent/uart.log

# 或直接指定完整遠端路徑（覆蓋配置）
fwauto log "有錯誤嗎?" --log-path alice@10.0.0.1:/var/log/app.log
```
```

### Success Criteria

#### Automated Verification

- [ ] 配置測試通過：`pytest tests/test_log_config.py -v`
- [ ] ProjectConfig 擴展測試通過
- [ ] 配置路徑解析邏輯測試通過
- [ ] Linting 和 type checking 通過

#### Manual Verification

- [ ] 配置檔簡寫功能正常：
  - [ ] 設定 `[log]` section 後可用相對路徑
  - [ ] 設定 `remote_path` 後相對路徑正確組合
- [ ] 自訂 SSH 選項生效：
  - [ ] 設定 `ssh_options` 後 SCP 使用自訂選項
- [ ] CLI 路徑覆蓋配置：
  - [ ] CLI 指定完整 `user@host:path` 覆蓋配置
- [ ] 配置檔範例文檔清楚易懂

**Implementation Note**: 完成此階段並通過所有自動化測試後，暫停並手動測試配置功能，確認無誤後再繼續 Phase 3。

---

## Phase 3: 測試與文檔

### Overview

完善測試覆蓋率和文檔，確保品質和可維護性。

### 實施流程

```mermaid
graph TD
    START["Phase 3 開始"] --> A["撰寫單元測試"]
    A --> B["撰寫整合測試"]
    B --> C["更新文檔"]
    C --> D["範例和使用指南"]
    D --> END["Phase 3 完成"]

    style START fill:#90EE90
    style END fill:#90EE90
```

### Changes Required

#### 1. 新增單元測試檔案

**檔案**：`tests/test_remote_log_access.py`（新檔案）

```python
"""
Tests for remote log access functionality in log_node.
"""

import os
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import pytest

from fwauto.nodes.log import (
    _is_remote_path,
    _parse_remote_path,
    _fetch_remote_log,
    _get_temp_log_dir,
    _cleanup_temp_files,
    _resolve_config_path,
)


class TestRemotePathDetection:
    """測試遠端路徑偵測"""

    def test_is_remote_path_with_valid_remote(self):
        """測試有效的遠端路徑格式"""
        assert _is_remote_path("kent@192.168.50.1:/home/kent/uart.log") is True
        assert _is_remote_path("user@host:/path/to/file.log") is True
        assert _is_remote_path("root@10.0.0.1:/var/log/app.log") is True

    def test_is_remote_path_with_local_paths(self):
        """測試本地路徑返回 False"""
        assert _is_remote_path("logs/uart.log") is False
        assert _is_remote_path("/absolute/path/uart.log") is False
        assert _is_remote_path("./relative/path.log") is False

    def test_is_remote_path_with_invalid_formats(self):
        """測試無效格式返回 False"""
        assert _is_remote_path("user@host") is False  # 缺少路徑
        assert _is_remote_path("host:/path") is False  # 缺少使用者
        assert _is_remote_path("@host:/path") is False  # 空使用者


class TestRemotePathParsing:
    """測試遠端路徑解析"""

    def test_parse_valid_remote_path(self):
        """測試解析有效的遠端路徑"""
        user, host, path = _parse_remote_path("kent@192.168.50.1:/home/kent/uart.log")
        assert user == "kent"
        assert host == "192.168.50.1"
        assert path == "/home/kent/uart.log"

    def test_parse_remote_path_with_ipv6(self):
        """測試 IPv6 主機"""
        user, host, path = _parse_remote_path("user@[::1]:/path/file.log")
        assert user == "user"
        assert host == "[::1]"
        assert path == "/path/file.log"

    def test_parse_invalid_remote_path(self):
        """測試無效格式拋出異常"""
        with pytest.raises(ValueError):
            _parse_remote_path("invalid-format")

        with pytest.raises(ValueError):
            _parse_remote_path("user@host")  # 缺少路徑


class TestFetchRemoteLog:
    """測試 SCP 下載功能"""

    @patch("subprocess.run")
    def test_fetch_remote_log_success(self, mock_run):
        """測試成功下載遠端日誌"""
        mock_run.return_value = Mock(returncode=0, stdout="", stderr="")

        temp_dir = Path("/tmp/test")
        result = _fetch_remote_log("kent@host:/path/log", temp_dir)

        # 驗證返回本地路徑
        assert result.startswith(str(temp_dir))
        assert result.endswith(".log")

        # 驗證 SCP 命令被呼叫
        mock_run.assert_called_once()
        call_args = mock_run.call_args[0][0]
        assert call_args[0] == "scp"
        assert "kent@host:/path/log" in call_args

    @patch("subprocess.run")
    def test_fetch_remote_log_connection_refused(self, mock_run):
        """測試連線失敗錯誤"""
        mock_run.side_effect = subprocess.CalledProcessError(
            1, "scp", stderr="Connection refused"
        )

        temp_dir = Path("/tmp/test")
        with pytest.raises(RuntimeError) as exc_info:
            _fetch_remote_log("kent@host:/path/log", temp_dir)

        assert "Cannot connect" in str(exc_info.value)

    @patch("subprocess.run")
    def test_fetch_remote_log_permission_denied(self, mock_run):
        """測試權限錯誤"""
        mock_run.side_effect = subprocess.CalledProcessError(
            1, "scp", stderr="Permission denied (publickey)"
        )

        temp_dir = Path("/tmp/test")
        with pytest.raises(RuntimeError) as exc_info:
            _fetch_remote_log("kent@host:/path/log", temp_dir)

        assert "authentication failed" in str(exc_info.value).lower()

    @patch("subprocess.run")
    def test_fetch_remote_log_file_not_found(self, mock_run):
        """測試檔案不存在錯誤"""
        mock_run.side_effect = subprocess.CalledProcessError(
            1, "scp", stderr="No such file or directory"
        )

        temp_dir = Path("/tmp/test")
        with pytest.raises(RuntimeError) as exc_info:
            _fetch_remote_log("kent@host:/path/log", temp_dir)

        assert "not found" in str(exc_info.value).lower()

    @patch("subprocess.run")
    def test_fetch_remote_log_timeout(self, mock_run):
        """測試超時錯誤"""
        mock_run.side_effect = subprocess.TimeoutExpired("scp", 30)

        temp_dir = Path("/tmp/test")
        with pytest.raises(RuntimeError) as exc_info:
            _fetch_remote_log("kent@host:/path/log", temp_dir)

        assert "timeout" in str(exc_info.value).lower()

    @patch("subprocess.run")
    def test_fetch_remote_log_with_custom_ssh_options(self, mock_run):
        """測試自訂 SSH 選項"""
        mock_run.return_value = Mock(returncode=0, stdout="", stderr="")

        temp_dir = Path("/tmp/test")
        ssh_opts = "-o ConnectTimeout=10 -o BatchMode=yes"

        _fetch_remote_log("kent@host:/path/log", temp_dir, ssh_options=ssh_opts)

        # 驗證自訂選項被使用
        call_args = mock_run.call_args[0][0]
        assert "-o" in call_args
        assert "ConnectTimeout=10" in call_args


class TestTempDirManagement:
    """測試暫存目錄管理"""

    def test_get_temp_log_dir_creates_directory(self, tmp_path):
        """測試暫存目錄自動建立"""
        project_root = tmp_path / "project"

        temp_dir = _get_temp_log_dir(str(project_root))

        assert temp_dir.exists()
        assert temp_dir == project_root / ".fwauto" / "logs"

    def test_cleanup_temp_files_removes_file(self, tmp_path):
        """測試暫存檔案清理"""
        temp_file = tmp_path / "test.log"
        temp_file.write_text("test")

        _cleanup_temp_files(str(temp_file), is_temp=True)

        assert not temp_file.exists()

    def test_cleanup_temp_files_skips_non_temp(self, tmp_path):
        """測試非暫存檔案不被清理"""
        normal_file = tmp_path / "normal.log"
        normal_file.write_text("test")

        _cleanup_temp_files(str(normal_file), is_temp=False)

        assert normal_file.exists()


class TestConfigPathResolution:
    """測試配置檔路徑解析"""

    @patch("fwauto.nodes.log.ProjectConfig")
    def test_resolve_config_path_with_remote_config(self, mock_config_class):
        """測試配置檔遠端資訊解析"""
        mock_config = Mock()
        mock_config.log_remote_host = "192.168.50.1"
        mock_config.log_remote_user = "kent"
        mock_config.log_remote_path = "/home/kent/logs"
        mock_config.log_ssh_options = "-o ConnectTimeout=10"
        mock_config_class.return_value = mock_config

        resolved, remote_cfg = _resolve_config_path("uart.log", "/project")

        assert resolved == "kent@192.168.50.1:/home/kent/logs/uart.log"
        assert remote_cfg["ssh_options"] == "-o ConnectTimeout=10"

    @patch("fwauto.nodes.log.ProjectConfig")
    def test_resolve_config_path_with_absolute_path(self, mock_config_class):
        """測試絕對路徑 + 配置"""
        mock_config = Mock()
        mock_config.log_remote_host = "192.168.50.1"
        mock_config.log_remote_user = "kent"
        mock_config.log_ssh_options = None
        mock_config_class.return_value = mock_config

        resolved, remote_cfg = _resolve_config_path("/home/kent/uart.log", "/project")

        assert resolved == "kent@192.168.50.1:/home/kent/uart.log"

    def test_resolve_config_path_already_remote(self):
        """測試已是遠端格式直接返回"""
        remote_path = "alice@10.0.0.1:/var/log/app.log"

        resolved, remote_cfg = _resolve_config_path(remote_path, "/project")

        assert resolved == remote_path
        assert remote_cfg == {}

    @patch("fwauto.nodes.log.ProjectConfig")
    def test_resolve_config_path_no_remote_config(self, mock_config_class):
        """測試無遠端配置返回原路徑"""
        mock_config = Mock()
        mock_config.log_remote_host = None
        mock_config.log_remote_user = None
        mock_config_class.return_value = mock_config

        resolved, remote_cfg = _resolve_config_path("logs/uart.log", "/project")

        assert resolved == "logs/uart.log"
        assert remote_cfg == {}
```

#### 2. 擴展現有測試

**檔案**：`tests/test_story_004_log_analysis_node.py`

**新增回歸測試**：
```python
class TestStory004LogAnalysisNode:
    # ... 現有測試 ...

    def test_log_node_local_path_backward_compatibility(self):
        """
        回歸測試：確保本地路徑功能保持不變

        Given: 本地日誌檔案路徑
        When: 調用 log_node
        Then: 應該使用現有邏輯，不嘗試遠端下載
        """
        from src.fwauto.nodes.log import log_node

        test_state: FirmwareState = {
            "user_prompt": "測試分析",
            "project_root": os.getcwd(),
            "project_initialized": True,
            "build_status": "pending",
            "build_errors": [],
            "build_log": "",
            "flash_status": "pending",
            "flash_errors": [],
            "flash_log": "",
            "iteration": 0,
            "max_retries": 3,
            "execution_mode": "log",
            "command": "log-analysis",
            "command_args": {"analysis_prompt": "測試分析"},
            "mode_metadata": None,
            "command_error": None,
            "log_file_path": "prompts/log.md",  # 本地檔案
            "log_analysis_result": None,
        }

        with patch("src.fwauto.nodes.log.query") as mock_query:
            async def mock_response():
                yield "測試回應"

            mock_query.return_value = mock_response()

            # 不應該嘗試 SCP 下載
            with patch("src.fwauto.nodes.log._fetch_remote_log") as mock_fetch:
                result = log_node(test_state)

                # 驗證沒有呼叫遠端下載
                mock_fetch.assert_not_called()

                # 驗證結果正常
                assert isinstance(result, dict)
                assert "log_analysis_result" in result
```

#### 3. 新增配置測試

**檔案**：`tests/test_log_config.py`（新檔案）

```python
"""
Tests for log configuration in ProjectConfig.
"""

from pathlib import Path
import pytest
from fwauto.config import ProjectConfig


class TestLogConfig:
    """測試日誌配置功能"""

    def test_log_config_default_values(self, tmp_path):
        """測試預設配置值"""
        project_root = tmp_path / "project"
        project_root.mkdir()
        (project_root / ".fwauto").mkdir()

        config = ProjectConfig(project_root)

        assert config.log_remote_host is None
        assert config.log_remote_user is None
        assert config.log_remote_path is None
        assert config.log_ssh_options is None

    def test_log_config_custom_values(self, tmp_path):
        """測試自訂配置值"""
        project_root = tmp_path / "project"
        project_root.mkdir()
        fwauto_dir = project_root / ".fwauto"
        fwauto_dir.mkdir()

        # 寫入配置檔
        config_file = fwauto_dir / "config.toml"
        config_file.write_text("""
[log]
remote_host = "192.168.50.1"
remote_user = "kent"
remote_path = "/home/kent/logs"
ssh_options = "-o ConnectTimeout=10"
""")

        config = ProjectConfig(project_root)

        assert config.log_remote_host == "192.168.50.1"
        assert config.log_remote_user == "kent"
        assert config.log_remote_path == "/home/kent/logs"
        assert config.log_ssh_options == "-o ConnectTimeout=10"
```

#### 4. 更新使用文檔

**檔案**：`README.md`

**新增日誌分析章節**：
```markdown
### 日誌分析

FWAuto 支援使用 AI 分析韌體日誌，可處理本地和遠端日誌檔案。

#### 基本使用

```bash
# 分析本地日誌
fwauto log "有任何錯誤嗎?" --log-path logs/uart.log

# 分析遠端日誌（AM62x 環境）
fwauto log "LED 狀態如何?" --log-path kent@192.168.50.1:/home/kent/uart.log
```

#### 配置遠端日誌（推薦）

在 `.fwauto/config.toml` 設定遠端主機資訊後，可簡化命令：

```toml
[log]
remote_host = "192.168.50.1"
remote_user = "kent"
remote_path = "/home/kent/logs"  # 預設目錄（可選）
```

設定後可直接使用相對路徑：

```bash
# 自動解析為 kent@192.168.50.1:/home/kent/logs/uart.log
fwauto log "分析錯誤" --log-path uart.log
```

#### SSH 設定

遠端日誌存取需要 SSH 無密碼登入。請確保：

1. SSH 金鑰已設定：
```bash
# 生成金鑰（如果沒有）
ssh-keygen -t ed25519

# 複製公鑰到遠端主機
ssh-copy-id kent@192.168.50.1
```

2. 測試連線：
```bash
ssh kent@192.168.50.1 "echo Connection OK"
```

#### 進階選項

**自訂 SSH 選項**：

```toml
[log]
remote_host = "192.168.50.1"
remote_user = "kent"
ssh_options = "-o ConnectTimeout=10 -o ServerAliveInterval=30"
```

**暫存檔案位置**：

遠端日誌下載到 `.fwauto/logs/` 目錄，分析完成後自動清理。
```

#### 5. 新增故障排除文檔

**檔案**：`docs/troubleshooting-remote-log.md`（新檔案）

```markdown
# 遠端日誌存取故障排除

## 常見錯誤

### 1. "Cannot connect to remote host"

**原因**：無法連線到遠端主機

**解決方案**：
```bash
# 檢查網路連線
ping 192.168.50.1

# 檢查 SSH 服務
ssh kent@192.168.50.1 "echo OK"

# 檢查防火牆
# 確保 SSH port (22) 開放
```

### 2. "SSH authentication failed"

**原因**：SSH 認證失敗

**解決方案**：
```bash
# 確認 SSH 金鑰已複製
ssh-copy-id kent@192.168.50.1

# 測試金鑰認證
ssh -o PasswordAuthentication=no kent@192.168.50.1 "echo OK"

# 檢查金鑰權限
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub
```

### 3. "Remote log file not found"

**原因**：遠端檔案不存在

**解決方案**：
```bash
# 確認檔案路徑
ssh kent@192.168.50.1 "ls -la /home/kent/uart.log"

# 檢查權限
ssh kent@192.168.50.1 "cat /home/kent/uart.log | head"
```

### 4. "SCP timeout"

**原因**：網路延遲或主機無回應

**解決方案**：
```toml
# 增加超時時間
[log]
ssh_options = "-o ConnectTimeout=30"
```

## 除錯技巧

### 啟用詳細日誌

```bash
# 執行 FWAuto 時啟用 debug 模式
fwauto log "問題" --log-path kent@host:/path --verbose
```

### 手動測試 SCP

```bash
# 模擬 FWAuto 的 SCP 命令
scp -o StrictHostKeyChecking=no \
    -o UserKnownHostsFile=/dev/null \
    -o ConnectTimeout=5 \
    kent@192.168.50.1:/home/kent/uart.log \
    /tmp/test.log
```

### 檢查暫存目錄

```bash
# 查看下載的日誌檔案
ls -la .fwauto/logs/

# 手動清理
rm -rf .fwauto/logs/*
```
```

### Success Criteria

#### Automated Verification

- [ ] 所有單元測試通過：`pytest tests/ -v`
- [ ] 測試覆蓋率 > 80%：`pytest --cov=src/fwauto/nodes/log --cov-report=html`
- [ ] 回歸測試通過（本地路徑功能）
- [ ] 配置測試通過
- [ ] Linting 和 type checking 全部通過

#### Manual Verification

- [ ] README 文檔清楚易懂
- [ ] 故障排除文檔有幫助
- [ ] 配置範例可直接使用
- [ ] 所有使用場景都有文檔說明
- [ ] 測試覆蓋所有錯誤情況

**Implementation Note**: 完成此階段並通過所有測試後，進行完整的端到端測試，確認所有功能正常運作。

---

## Testing Strategy

### 單元測試

**測試檔案**：
- `tests/test_remote_log_access.py` - 遠端存取功能
- `tests/test_log_config.py` - 配置系統擴展
- `tests/test_story_004_log_analysis_node.py` - 回歸測試

**Mock 策略**：
```python
# Mock subprocess.run
@patch("subprocess.run")
def test_scp_download(mock_run):
    mock_run.return_value = Mock(returncode=0)
    ...

# Mock ProjectConfig
@patch("fwauto.nodes.log.ProjectConfig")
def test_config_resolution(mock_config_class):
    mock_config = Mock()
    mock_config.log_remote_host = "host"
    ...
```

**測試覆蓋範圍**：
- ✅ 遠端路徑偵測（各種格式）
- ✅ 路徑解析（成功 + 錯誤）
- ✅ SCP 下載（成功 + 各種錯誤）
- ✅ 暫存檔案管理
- ✅ 配置路徑解析
- ✅ 向後相容性

### 整合測試

**可選**（需要真實 SSH 環境）：

```python
# tests/integration/test_remote_log_real.py
@pytest.mark.integration
@pytest.mark.skipif(not os.getenv("SSH_TEST_HOST"), reason="No SSH test host")
def test_real_ssh_download():
    """使用真實 SSH 連線測試（需環境變數）"""
    host = os.getenv("SSH_TEST_HOST")
    user = os.getenv("SSH_TEST_USER")
    ...
```

### 手動測試檢查清單

- [ ] 本地日誌分析（確保向後相容）
- [ ] CLI 遠端路徑分析
- [ ] 配置檔遠端路徑分析
- [ ] 各種錯誤情況（連線、權限、檔案不存在）
- [ ] 自訂 SSH 選項
- [ ] 暫存檔案清理

## Performance Considerations

### SCP 下載性能

**預期性能**：
- 小型日誌（< 1MB）：< 1 秒
- 中型日誌（1-10MB）：1-3 秒
- 大型日誌（> 10MB）：> 3 秒

**優化策略**（未來）：
- 壓縮傳輸：`scp -C`
- 限制下載大小：只下載最新 N 行

### 暫存空間管理

**當前策略**：分析完成後立即清理

**未來改進**（可選）：
- 快取機制：相同路徑不重複下載
- 定期清理：舊檔案自動刪除
- 大小限制：`.fwauto/logs/` 最大 100MB

## Migration Notes

### 向後相容性

**保證**：
- ✅ 現有本地路徑功能 100% 保持不變
- ✅ 無需修改現有專案配置
- ✅ 無需修改 CLI 使用方式

**新功能為可選**：
- 不設定 `[log]` section → 行為與現在完全相同
- 設定後 → 啟用遠端日誌功能

### 升級步驟

1. 更新 FWAuto 到新版本：`uv tool install --force --editable .`
2. （可選）設定 `.fwauto/config.toml` 的 `[log]` section
3. （可選）設定 SSH 無密碼登入
4. 測試：`fwauto log "測試" --log-path <path>`

## References

### 原始研究

- **研究報告**：`.ai/prpi/research/2025-11-05-am62x-log-node-remote-access.md`
- **相關計劃**：`.ai/prpi/plans/2025-11-03-fwauto-vm-ssh-integration.md`

### 程式碼參考

| 檔案路徑 | 說明 |
|---------|------|
| `src/fwauto/nodes/log.py` | log_node 主要實現 |
| `src/fwauto/config.py` | ProjectConfig 配置系統 |
| `src/fwauto/templates/sdk_am62x_linux/flash/flash.sh` | SSH/SCP 參考實現 |
| `tests/test_story_004_log_analysis_node.py` | 現有測試 |

### 外部參考

- [SSH Config](https://www.ssh.com/academy/ssh/config)
- [Python subprocess](https://docs.python.org/3/library/subprocess.html)
- [SCP Protocol](https://en.wikipedia.org/wiki/Secure_copy_protocol)

---

## 附錄：實施檢查清單

### Phase 1 檢查清單

- [ ] 新增 `_is_remote_path()` 函數
- [ ] 新增 `_parse_remote_path()` 函數
- [ ] 新增 `_fetch_remote_log()` 函數
- [ ] 新增 `_get_temp_log_dir()` 函數
- [ ] 新增 `_cleanup_temp_files()` 函數
- [ ] 擴展 `log_node()` 主邏輯
- [ ] 新增單元測試
- [ ] 手動測試 CLI 遠端路徑
- [ ] 確認本地路徑功能正常

### Phase 2 檢查清單

- [ ] 擴展 `ProjectConfig` 屬性
- [ ] 新增 `_resolve_config_path()` 函數
- [ ] 整合配置到 `log_node()`
- [ ] 更新 `docs/am62x.md`
- [ ] 新增配置測試
- [ ] 手動測試配置簡寫
- [ ] 手動測試自訂 SSH 選項

### Phase 3 檢查清單

- [ ] 完成 `tests/test_remote_log_access.py`
- [ ] 完成 `tests/test_log_config.py`
- [ ] 擴展回歸測試
- [ ] 更新 `README.md`
- [ ] 新增 `docs/troubleshooting-remote-log.md`
- [ ] 測試覆蓋率 > 80%
- [ ] 所有測試通過
- [ ] 文檔完整且清楚
