jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

206 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// log_buffer.go — M8-4Wails 控制台 log ring buffer
//
// 負責:
// 1. 2000 行 thread-safe ring buffer
// 2. LogLine 結構(含 level 解析)
// 3. Snapshot取最後 n 行(複製,不回傳 view
// 4. Rate limit1 秒 > 200 條時退化為「只寫檔不 emit」
//
// TDD ground truth
// - .autoflow/04-architecture/v2/control-panel.md §4.3§4.5
// - .autoflow/04-architecture/v2/server-lifecycle.md §4stdout/stderr pipe 捕捉)
//
// 本檔只做「資料結構」+「level 解析」+「rate limit 判定」。
// 實際的 logPump goroutine 與 Wails event emit 放在 server_control.go。
import (
"strings"
"sync"
"sync/atomic"
"time"
)
const (
// logBufferCap 是 ring buffer 行數上限。TDD 定版 2000。
logBufferCap = 2000
// logRateLimitWindow 是 rate limit 的視窗長度。
logRateLimitWindow = 1 * time.Second
// logRateLimitBurst 是視窗內允許 emit 的最大行數。超過就只寫檔不 emit。
logRateLimitBurst = 200
)
// LogLine 是 ring buffer 中的單一條目。
//
// 對應 TDD v2/control-panel.md §4.3 的 LogLine struct。
type LogLine struct {
// Ts 為 Unix 毫秒(方便前端 JS 直接 new Date(ts))。
Ts int64 `json:"ts"`
// Stream 為來源stdout / stderr / wails
Stream string `json:"stream"`
// Line 為原始那一行文字。
Line string `json:"line"`
// Level 為解析後的等級info / warn / error / debug解析不到則為 ""。
Level string `json:"level,omitempty"`
}
// LogBuffer 是 thread-safe 的 ring buffer。
type LogBuffer struct {
mu sync.Mutex
lines [logBufferCap]LogLine
head int // 下一筆寫入位置
size int // 目前已存筆數(最多 logBufferCap
// dropped 紀錄被覆寫的最舊條目總數(給 debug / 未來統計用)。
dropped uint64
// rateLimit 相關:用原子計數維持最小鎖定範圍
rlWindowStart atomic.Int64 // 視窗起點Unix nano
rlCount atomic.Int64 // 視窗內已 emit 的行數
}
// NewLogBuffer 建立一個新的 ring buffer。
func NewLogBuffer() *LogBuffer {
return &LogBuffer{}
}
// Append 在 buffer 最末端追加一行。buffer 滿時會覆寫最舊行。
//
// 這個方法只動 ring bufferrate limit 判定是獨立的 ShouldEmit。
func (b *LogBuffer) Append(l LogLine) {
b.mu.Lock()
defer b.mu.Unlock()
b.lines[b.head] = l
b.head = (b.head + 1) % logBufferCap
if b.size < logBufferCap {
b.size++
} else {
b.dropped++
}
}
// Snapshot 依插入順序回傳最後 n 行(複製,安全外流)。
// n <= 0 或 n > size → 回傳全部。
func (b *LogBuffer) Snapshot(n int) []LogLine {
b.mu.Lock()
defer b.mu.Unlock()
if b.size == 0 {
return []LogLine{}
}
if n <= 0 || n > b.size {
n = b.size
}
out := make([]LogLine, 0, n)
// start = 最舊條目的 index
start := (b.head - b.size + logBufferCap) % logBufferCap
skip := b.size - n
for i := 0; i < b.size; i++ {
if i < skip {
continue
}
idx := (start + i) % logBufferCap
out = append(out, b.lines[idx])
}
return out
}
// Reset 清空 ring bufferClearLogs binding 用)。
// 不動磁碟檔;也不重置 rate limit counter。
func (b *LogBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.size = 0
}
// Size 回傳目前存了多少行thread-safe
func (b *LogBuffer) Size() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.size
}
// Dropped 回傳被覆寫掉的最舊條目總數。
func (b *LogBuffer) Dropped() uint64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.dropped
}
// ShouldEmit 判定此刻是否可以 emit Wails event。
//
// 規則:每 1 秒視窗內最多 emit logRateLimitBurst (=200) 行;超過 → 回 false
// 呼叫端只寫檔 + append buffer不做 EventsEmit。
//
// 實作採「固定視窗」而非「sliding window」夠簡單且效果可接受。
func (b *LogBuffer) ShouldEmit() bool {
now := time.Now().UnixNano()
windowStart := b.rlWindowStart.Load()
if now-windowStart >= int64(logRateLimitWindow) {
// 新視窗:把 windowStart CAS 為 now成功者負責 reset count
if b.rlWindowStart.CompareAndSwap(windowStart, now) {
b.rlCount.Store(0)
}
}
// 在目前視窗內 +1超過 burst 就退回 false
n := b.rlCount.Add(1)
return n <= logRateLimitBurst
}
// parseLogLevel 從一行 log 抽出 levelbest-effort
//
// 支援格式(大小寫不敏感):
// - `... [INFO] ...` / `... [WARN] ...` / `... [ERROR] ...` / `... [DEBUG] ...`
// - `INFO:` / `WARN:` / `ERROR:` / `DEBUG:`
// - `[GIN] 200 | ...` → info< 400
// - `[GIN] 4xx | ...` → warn
// - `[GIN] 5xx | ...` → error
// - 其他 → 空字串(前端當 info 顯示)
//
// 解析失敗不是錯誤level 為空字串即可。
func parseLogLevel(line string) string {
if line == "" {
return ""
}
upper := strings.ToUpper(line)
// 明示 bracket 標記(優先)
switch {
case strings.Contains(upper, "[ERROR]"), strings.Contains(upper, " ERROR:"), strings.HasPrefix(upper, "ERROR:"):
return "error"
case strings.Contains(upper, "[WARN]"), strings.Contains(upper, "[WARNING]"), strings.Contains(upper, " WARN:"), strings.HasPrefix(upper, "WARN:"):
return "warn"
case strings.Contains(upper, "[INFO]"), strings.Contains(upper, " INFO:"), strings.HasPrefix(upper, "INFO:"):
return "info"
case strings.Contains(upper, "[DEBUG]"), strings.Contains(upper, " DEBUG:"), strings.HasPrefix(upper, "DEBUG:"):
return "debug"
}
// Gin access log`[GIN] 200 | 1.2ms | GET /xxx`
if strings.HasPrefix(line, "[GIN]") {
// 找 3 位數 status
for i := 0; i+3 <= len(line); i++ {
if line[i] >= '0' && line[i] <= '9' &&
i+2 < len(line) &&
line[i+1] >= '0' && line[i+1] <= '9' &&
line[i+2] >= '0' && line[i+2] <= '9' {
code := (int(line[i]-'0'))*100 + int(line[i+1]-'0')*10 + int(line[i+2]-'0')
switch {
case code >= 500:
return "error"
case code >= 400:
return "warn"
default:
return "info"
}
}
}
}
return ""
}