從 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>
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
package main
|
||
|
||
// log_buffer.go — M8-4:Wails 控制台 log ring buffer
|
||
//
|
||
// 負責:
|
||
// 1. 2000 行 thread-safe ring buffer
|
||
// 2. LogLine 結構(含 level 解析)
|
||
// 3. Snapshot:取最後 n 行(複製,不回傳 view)
|
||
// 4. Rate limit:1 秒 > 200 條時退化為「只寫檔不 emit」
|
||
//
|
||
// TDD ground truth:
|
||
// - .autoflow/04-architecture/v2/control-panel.md §4.3–§4.5
|
||
// - .autoflow/04-architecture/v2/server-lifecycle.md §4(stdout/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 buffer;rate 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 buffer(ClearLogs 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 抽出 level(best-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 ""
|
||
}
|