依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 KiB
Reviewer 審查 M8-4b 補丁(2026-04-15)
摘要
- 審查對象:M8-4b 補丁(3 Major + Stage 4 timeout 修復)
- 審查檔案:
visiona-local/startup_pipeline.go(L385-423 新增 helpers)、visiona-local/server_control.go(L167-223 startInternal 守門、L630-652 probe timeout、L867-937 RestartStartupSequence + test hook)、visiona-local/app.go(L109-119 struct、L1676-1693 openBrowser var)、visiona-local/startup_pipeline_test.go(L393-660 4 個 regression test) - 總結論:✅ 通過 — 3 Major 全部正確修復、Stage 4 timeout 改好、4 個 regression test 完整覆蓋且全綠。Build/Vet/Test/Race(count=2)在
visiona-local與server兩個 module 都 PASS。 - 3 Major 修復狀況:M-1 ✅ / M-2 ✅ / M-3 ✅
- 問題統計:Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 阻擋 M8-10 嗎:否,可推進 M8-10。
A. HasFailedStage helper 正確性
位置:visiona-local/startup_pipeline.go L385-404
- 邏輯正確 ✅:
current == -1(FailStage會把 current 設為 -1)或迴圈檢查stages[1..6].status == "failed"(defensive path)。 - Lock 保護 ✅:
p.mu.Lock()+ deferUnlock()。mu在 struct 是sync.Mutex(L98),沒 RLock 可用 — 任務敘述提到的「RLock」不適用,用Lock是正確選擇。效能影響可忽略(只在 startInternal error 路徑讀一次)。 - 文件註解清楚 ✅:L386-391 解釋語義與對 M-1 的作用。
- Test 覆蓋 4 case ✅:
TestStartupPipeline_HasFailedStage(L537-566)分別驗證:new / running / FailStage 後(current=-1)/ 某 stage status=failed 但 current 未同步(defensive)。
B. IsInColdStart helper 正確性
位置:visiona-local/startup_pipeline.go L406-423
- 邏輯正確 ✅:
current >= 1 && current <= startupTotalStages(startupTotalStages=6),stage 7(ready)與 current=-1(failed)、current=0(未 Start)都回 false。 - Lock 保護 ✅:同
HasFailedStage,用Lock/Unlock。 - Test 覆蓋邊界 ✅:
TestStartupPipeline_IsInColdStart(L578-606)覆蓋 current ∈ {0, 1..6, 7, -1},迴圈確認 1..6 全 true,其餘全 false。
C. startInternal error 分支守門
位置:visiona-local/server_control.go L167-194
- 正確用
pipeline.HasFailedStage()✅(L176-179)。 pipeline == nilfallback ✅:pipelineHandled預設 false → 走原 setState + emit + sendCrashNotification 邏輯。- 非冷啟動路徑(RestartServer 等,pipeline 已 ready current=7,若此刻 Start 失敗 HasFailedStage=false)fallback 正常發通知 ✅。
- 註解(L168-175)解釋完整:說明為什麼守門 + 哪些路徑仍需 fallback。
- 不破壞 RestartServer(走
ctrl.Restart()→ctrl.Stop()+ctrl.Start())的錯誤處理 ✅:RestartServer 發生時 pipeline.current==7 或 0,HasFailedStage()回 false。
小提醒(非 Major):此守門建立在「pipeline FailStage 一定早於 startInternal return err」的假設上。實作上 startServerV2 每個 stage 失敗都立即 FailStage(見 server_control.go L437-448 / L554-566),再 return err,順序正確。若未來新增一條「不透過 FailStage 的錯誤返回路徑」,守門會被繞過。建議記為 Minor 追蹤(見 §L)。
D. openBrowser 守門
位置:visiona-local/server_control.go L202-222
pipeline.IsInColdStart() == false才執行 R5-D3 openBrowser ✅(L213-216)。- RestartServer 路徑(pipeline.current==7,
IsInColdStart()回 false)仍 openBrowser ✅。 pipeline == nil路徑inColdStart維持 false,fallback 仍 openBrowser ✅(L214-216)。- 註解(L204-212)清楚列出三種場景(冷啟動 / RestartServer / RestartStartupSequence)的預期行為,可維護性高。
E. openBrowser package-level var 改動
位置:visiona-local/app.go L1676-1693
- 改為
var openBrowser = openBrowserExec✅(L1681)。預設值是實作函式,production 透過 var 呼叫只多一個 indirection,效能影響可忽略。 - 所有呼叫端透過 var 呼叫:
app.goL268(runStartupStage5)、server_control.goL219(startInternal R5-D3)。Test 可替換 var 做 stub。 openBrowserExec保留為獨立函式(L1684-1693)作為預設實作,macOS/Windows/Linux 三分支原封不動 ✅。- Test 重置良好 ✅:
TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce用defer func() { openBrowser = original }()(L627)保證 test 結束還原。
F. RestartStartupSequence test 重構
位置:visiona-local/startup_pipeline_test.go L393-522
- 直接呼叫
a.RestartStartupSequence()✅(L439)— 不是手動複製 5 步驟。 - 用
restartStartFntest hook 攔截 Step 6 ✅(L424-433),spy 記錄呼叫、檢查 sentinel 是否已清、snapshot 新 pipeline。 callLog驗證順序 ✅(L497-516):pipelineCancel必須在startFn之前。- 驗證新 pipeline 建立 ✅(L466-486):stage1=completed / stage2=running / current=2。
pipelineCancelFn的context.CancelFunc型別允許 assign 任意func()✅(L407-410)。- 其他 side effect 驗證完整:proc 被清為 nil(L453-458 ForceKill 效果)、sentinel file 已刪除(L461-463)、啟動前 ctrl state 設為 Error 模擬 Retry 前置條件(L418)。
- 「若未來把 Step 4 移到 Step 2 之前」的概念驗證:test 雖無法直接抓 Step 4 ↔ Step 2 相對順序,但能抓「Step 4 在 Step 6 之前」(
sentinelClearedBeforeStartL489-491),滿足原 Reviewer 對 M-3 的核心期待(驗證真的呼叫 method、不是手動複製)。
Minor F-1(見 §L):newPipelineTestApp 的 a.ctx == nil,所以 RestartStartupSequence Step 5 的 watcher goroutine 分支不會啟動,L518-521 的「清理新啟動的 watcher」註解有點誤導(實際上 pipelineCancelFn 已在 Step 1 被清為 nil,之後也沒有被重設)。無功能影響,純註解細節。
G. TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce
位置:visiona-local/startup_pipeline_test.go L615-660
- 正確 stub
openBrowservar ✅(L621-627),defer 還原。 AutoOpenBrowser=true(L618)確保 runStartupStage5 會走 openBrowser 分支(不 skip)。restartStartFn內模擬 server 啟動成功:設 fake proc port=12345、setState(Running)、手動推進 pipeline stages 到 stage 5 running ✅(L630-645)。這讓runStartupStage5能從ctrl.proc.port取到 URL 並呼叫 openBrowser。- 斷言
openCount == 1✅(L652-654),錯誤訊息清楚標示 want 1。 - 驗證冷啟動路徑下「startInternal 的 R5-D3 open」不會發生 — 因為 restartStartFn 替換了
ctrl.Start(),完全 bypass startInternal,所以實際上這個 test 驗證的是 runStartupStage5 本身只會 open 一次。
Minor G-1(見 §L):此 test 沒有實際經過 startInternal 的 R5-D3 分支(restartStartFn 完全替換 ctrl.Start),所以嚴格來說它驗證的是「runStartupStage5 本身的 open 次數」,而不是「冷啟動中 startInternal + runStartupStage5 的 open 次數總和」。要完整驗證 M-2,需要一個能跑 startInternal 的整合 test(但 startInternal 會 spawn python server 無法 unit test)。現況可接受,因為 IsInColdStart 邏輯已被 B 節 TestStartupPipeline_IsInColdStart 單獨驗證,startInternal 的守門條件 !inColdStart 是純布林邏輯,靜態即可推得正確。
H. Stage 4 probe timeout 2s
位置:visiona-local/server_control.go L630-652
- 註解說明原因 ✅:L634-636 解釋 TDD §3「秒回即算完成」+「2s 足以涵蓋正常 latency」。
- 不破壞既有 test ✅:Stage 4 相關 test 全綠(
go test -count=1PASS)。 - 逾時仍算完成 ✅(L647-651 不論 err 或 status 都 CompleteStage(4)),符合 TDD §3 設計意圖。
I. Build/Test/Race 結果
| 指令 | 結果 |
|---|---|
cd visiona-local && go build . |
PASS(無輸出) |
cd visiona-local && go vet ./... |
PASS(無輸出) |
cd visiona-local && go test -count=1 ./... |
PASS(8.210s) |
cd visiona-local && go test -race -count=2 ./... |
PASS(16.225s) |
cd server && go build ./... |
PASS |
cd server && go vet ./... |
PASS |
cd server && go test -count=1 ./... |
PASS(api/handlers/ws/device/model 全通過) |
cd server && go test -race ./... |
PASS |
4 個新 test 單獨跑 verbose:
--- PASS: TestStartupPipeline_RestartStartupSequence_StepsExecution (0.00s)
--- PASS: TestStartupPipeline_HasFailedStage (0.00s)
--- PASS: TestStartupPipeline_IsInColdStart (0.00s)
--- PASS: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce (0.00s)
J. 合併檢查
git diff --stat 看過全部變更,無衝突標記、無重複區塊。合併狀況:
- M8-4 原版 + M8-4 補丁(Stop/ForceKill/handleWatchFailure/logPump):
ForceKillL273-291 的 cancelWatcher 順序修復仍在,RestartStartupSequenceStep 2 正確利用 ✅。 - M8-4b 原版(startServerV2 hook + probe + RestartStartupSequence):補丁新增 helpers 與守門邏輯未動到既有 pipeline struct 欄位與 stage hook 位置 ✅。
- MAJ-4 補丁(shutdown-imminent broadcast + notifyShutdownImminent):不觸及 startup pipeline / server_control 的 startInternal / RestartStartupSequence 區塊 ✅。
grep未見<<<<<<</>>>>>>>合併標記。
K. 3 Major 症狀驗證
M-1(重複 OS 通知)
症狀:冷啟動 stage 3 失敗 → emitError 發通知 A → startInternal error 分支再發通知 B。
修復後的流程:
startServerV2失敗 →pipeline.FailStage(3, err)→emitError發通知「第 3 階段失敗」+ setState(Error) + current = -1- startInternal 收 err →
HasFailedStage()回 true →pipelineHandled = true→ skip setState/emit/sendCrashNotification - 只剩一個通知 ✅
症狀消失確認:Major M-1 修復路徑正確,唯一前提是「startServerV2 的所有失敗路徑都走 FailStage」— 已確認 L437-448(stage 2)、L554-566(stage 3)、stage 4 只在 timeout 仍 complete 不 fail,符合條件。
M-2(重複開瀏覽器)
症狀:冷啟動成功 → runStartupStage5 的 openBrowser 呼叫 1 次 + startInternal R5-D3 的 openBrowser 呼叫 1 次 = 2 次。
修復後的流程:
- 冷啟動 → startServerV2 成功 → startInternal 檢查
IsInColdStart()(此時 current ∈ 1..6)→ 回 true → skip R5-D3 openBrowser - startServerV2 返回後,app.startup 呼叫
runStartupStage5→ openBrowser 1 次 ✅ - RestartServer 路徑:pipeline.current==7 → IsInColdStart 回 false → R5-D3 正常 open ✅
症狀消失確認:冷啟動 openBrowser 只被呼叫一次。TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 驗證 openCount==1。
M-3(Retry test 重構)
症狀:test 手動複製 Step 1-5,改步驟順序抓不到。
修復後:直接呼叫 a.RestartStartupSequence() + callLog spy。若未來調整步驟順序(cancel 晚於 start),callLog 順序斷言會 fail ✅;若把 sentinel 清除改到 start 之後(Step 4 → Step 6 之後),sentinelClearedBeforeStart 斷言會 fail ✅。
概念驗證:test 能抓到「cancel 必須早於 start」與「sentinel 必須在 start 前清」兩個順序不變量,涵蓋 M-3 的核心意圖。
L. 問題清單
Critical
(無)
Major
(無)
Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|---|---|---|---|
| m-1 | visiona-local/startup_pipeline_test.go |
L518-521 | 註解說「清理新啟動的 watcher goroutine」,但 newPipelineTestApp 的 a.ctx == nil,Step 5 的 watcher 分支不會啟動,pipelineCancelFn 在 Step 1 已被清為 nil。註解誤導但無功能影響 |
改註解為「若 ctx 存在則清理重建的 watcher(此 test 走 nil ctx 分支,為 safety)」 |
| m-2 | visiona-local/server_control.go |
L167-194 | pipelineHandled 守門建立在「startServerV2 所有失敗路徑都走 FailStage」的隱性假設上,若未來新增一條不透過 FailStage return err 的路徑,M-1 症狀會復活 |
在 startServerV2 函式開頭加註解「所有 err return 前必須先 FailStage」;或改用 explicit flag(如 a.ctrl.suppressFallbackError)由 emitError 設,startInternal 讀後 clear |
Suggestion
| # | 內容 |
|---|---|
| s-1 | TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 嚴格來說驗證的是 runStartupStage5 本身只 open 一次(因為 restartStartFn 替換了 ctrl.Start,完全 bypass 了 startInternal 的 R5-D3 分支)。M-2 的守門邏輯(IsInColdStart() == false 才 open)是靠 TestStartupPipeline_IsInColdStart 單獨驗證的 pure boolean 邏輯。兩個 test 合起來足以推得正確性,但若希望更貼近「真實 M-2 症狀」,可另加一個 test 透過 a.ctrl.startInternal 直接呼叫(需要先在 ctrl 加 test-only hook 允許跳過 spawn),非必要 |
M. 結論
判定:✅ 通過
補丁對 M8-4b 原版 Reviewer 提出的 3 Major 全部正確修復:
- M-1(重複通知) — 新增
HasFailedStage()helper +startInternalerror 分支守門,邏輯正確、lock 保護得當,regression test(TestStartupPipeline_HasFailedStage)覆蓋 4 case 全通過。 - M-2(重複開瀏覽器) — 新增
IsInColdStart()helper +startInternalR5-D3 守門 +openBrowser改 package-level var 以便 stub,regression test(TestStartupPipeline_IsInColdStart+TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce)驗證完整。 - M-3(Retry test 重構) — 新增
restartStartFntest hook + 重構TestStartupPipeline_RestartStartupSequence_StepsExecution為直接呼叫 method + callLog spy 驗證順序,抓得到 cancel↔start 與 sentinel↔start 的順序不變量。
Stage 4 probe timeout 從 5s 改為 2s,註解說明符合 TDD 原意。
工程品質:
- Build / Vet / Test / Race(-count=2)在 visiona-local 與 server 兩個 module 全綠
- 合併乾淨,無衝突標記
- 註解詳盡(每個 helper 都有中文註解解釋「為什麼」與「哪些場景走 fallback」)
- Lock 使用正確(
sync.Mutex+Lock/Unlock,沒有 race condition) - Test hook 透過 struct field 注入,不污染正式路徑(
restartStartFn預設 nil)
不阻擋 M8-10。Minor 2 項 + Suggestion 1 項可記為技術債,不強制當下處理。
優點:
- Helper 命名清楚(
HasFailedStage/IsInColdStart語義一眼就懂) - 守門邏輯對
pipeline == nilfallback 處理完整,不會因單測環境或非冷啟動路徑被誤傷 - 4 個 regression test 覆蓋面完整:helper 本身 + 實際 RestartStartupSequence 流程 + openBrowser 呼叫次數
- 透過改
openBrowser為 package-level var 解決測試 stub 問題,是 Go idiomatic 的做法 restartStartFntest hook 設計乾淨:預設 nil → 走正式a.ctrl.Start(),單測替換即可,不會意外漏到 production- 2s probe timeout 的註解說明了「為什麼不是 5s」— 文件化設計決策