diff --git a/local-tool/visiona-local/server_control.go b/local-tool/visiona-local/server_control.go index 1138a7e..e193b3f 100644 --- a/local-tool/visiona-local/server_control.go +++ b/local-tool/visiona-local/server_control.go @@ -162,6 +162,32 @@ func (c *ServerController) startInternal(preferredPort int) error { // 3. 切到 Starting c.setState(ServerStateStarting, "") + // 3.5. 非冷啟動路徑(pipeline 已完成或已失敗)自動重建 pipeline,讓前端 + // 5 階段 UI 能跟著 Restart / StopServer+StartServer 更新狀態。 + // cold-start(pipeline current 在 [1..6])不動,避免打斷正在進行中的 + // 正常啟動流程。 + // - current > totalStages (7) → markReady 已完成 → Restart 路徑 + // - current == -1 → FailStage 已停 → Retry 路徑(但這條 + // 一般走 RestartStartupSequence 不走這裡) + // + // didRebuild 記錄本次是不是走 rebuild 路徑;是的話成功返回前會呼叫 + // runStartupStage5 把 Stage 5 openBrowser + CompleteStage 跑完,後續 + // Stage 6 由 watcher 的 sentinel poll 觸發 markReady。 + didRebuild := false + if c.app != nil && c.app.startupPipeline != nil { + pl := c.app.startupPipeline + pl.mu.Lock() + cur := pl.current + pl.mu.Unlock() + needRebuild := cur <= 0 || cur > startupTotalStages + if needRebuild { + // 清 sentinel file(和 RestartStartupSequence 一樣,避免階段 6 誤判) + removeSentinelFile(c.app.dataDir) + c.app.rebuildStartupPipeline() + didRebuild = true + } + } + // 4. 真的啟動 proc, err := c.app.startServerV2(preferredPort) if err != nil { @@ -199,25 +225,35 @@ func (c *ServerController) startInternal(preferredPort int) error { c.mu.Unlock() c.setState(ServerStateRunning, "") - // 5. R5-D3:每次 Start 成功都檢查 AutoOpenBrowser + // 5. Stage 5 openBrowser 處理 // - // M8-4b 補丁 M-2:若目前處於冷啟動 pipeline 中(stage 1-6),openBrowser 改由 - // runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次: - // - 冷啟動:app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次) - // → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5) - // Linux/xdg-open 會開兩個 tab;macOS/open 通常會聚合但 log 兩次。 - // - RestartServer:pipeline 已 ready (current==7),IsInColdStart() 回 false, - // startInternal 仍負責自己 open(RestartServer 不走 runStartupStage5)。 - // - RestartStartupSequence:pipeline 重建後走 Start → 這裡 skip,由上層的 - // runStartupStage5 負責(見 app.go Step 5)。 - inColdStart := false - if c.app != nil && c.app.startupPipeline != nil { - inColdStart = c.app.startupPipeline.IsInColdStart() - } - if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 { - url := fmt.Sprintf("http://127.0.0.1:%d", proc.port) - if err := openBrowser(url); err != nil { - fmt.Fprintf(os.Stderr, "[visiona-local] auto-open browser failed: %v\n", err) + // 三條路徑的分流(本段以 didRebuild 和 IsInColdStart 區分): + // (A) 冷啟動 (app.startup → ctrl.Start):didRebuild=false, + // IsInColdStart=true(current 仍在 2-4 範圍)→ 這段 skip,由 + // app.startup 後續呼叫 runStartupStage5 負責 + // (B) Restart / StopServer+StartServer:didRebuild=true(剛 rebuild + // 過 pipeline),IsInColdStart=true → 呼叫 runStartupStage5 完整 + // 跑 Stage 5 + Stage 6 watcher + // (C) RestartStartupSequence:呼叫上層 ctrl.Start(此函式),didRebuild + // = false(pipeline 已由 RestartStartupSequence 自己 rebuild), + // IsInColdStart=true → skip,由 RestartStartupSequence 後續呼叫 + // runStartupStage5 負責 + // (D) 舊有 fallback:pipeline == nil 或意外情境 → 自己 openBrowser 一次 + if didRebuild { + // Restart 路徑:自己跑 Stage 5(也會 CompleteStage(5) → Stage 6 watcher + // 會 poll sentinel → 最後 markReady,前端 5 階段面板會完整跑完) + c.app.runStartupStage5() + } else { + // 冷啟動和 RestartStartupSequence 由上層呼叫 runStartupStage5 + inColdStart := false + if c.app != nil && c.app.startupPipeline != nil { + inColdStart = c.app.startupPipeline.IsInColdStart() + } + if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 { + url := fmt.Sprintf("http://127.0.0.1:%d", proc.port) + if err := openBrowser(url); err != nil { + fmt.Fprintf(os.Stderr, "[visiona-local] auto-open browser failed: %v\n", err) + } } } return nil @@ -970,7 +1006,47 @@ func (a *App) RestartStartupSequence() error { // Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成) removeSentinelFile(a.dataDir) - // Step 5: 重建 StartupPipeline + // Step 5: 重建 StartupPipeline 並把 current 前進到 stage 2 running + a.rebuildStartupPipeline() + + // 呼叫 StartServer(內部會依序 CompleteStage(2..4)) + // Retry 情境允許 port fallback(cold start 模式) + // + // M8-4b 補丁 M-3:test hook — 單元測試可用 restartStartFn 替換 ctrl.Start, + // 避免在測試環境 spawn 真的 python server。正式環境走預設 a.ctrl.Start()。 + startFn := a.ctrl.Start + if a.restartStartFn != nil { + startFn = a.restartStartFn + } + if err := startFn(); err != nil { + // startServerV2 內已 FailStage,不需重複;err 仍 propagate 給前端讓 Retry 按鈕能 catch + return err + } + + // 階段 5:開瀏覽器(或 skip) + a.runStartupStage5() + // 階段 6 由 watcher poll sentinel file 觸發 + return nil +} + +// rebuildStartupPipeline 把 startup pipeline 重置為「Stage 1 completed + +// Stage 2 running」的初始狀態並啟動 watcher。 +// +// 呼叫時機: +// 1. RestartStartupSequence — 使用者按 Retry 按鈕時 recover failure +// 2. startInternal — 非冷啟動路徑進場時,若 pipeline 已完成或失敗則 +// 重建,讓 Restart / StopServer+StartServer 也能讓前端 5 階段 UI 更新 +// +// 前置:呼叫者必須先停舊 watcher goroutine(a.pipelineCancelFn)+ 清 +// sentinel file(removeSentinelFile)。本函式只負責重建 + 前進到 stage 2。 +func (a *App) rebuildStartupPipeline() { + // 停舊 watcher(防禦:呼叫者沒清時保險) + if a.pipelineCancelFn != nil { + a.pipelineCancelFn() + a.pipelineCancelFn = nil + } + + // 新建 pipeline instance a.startupPipeline = NewStartupPipeline(a) // 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身), @@ -1001,25 +1077,6 @@ func (a *App) RestartStartupSequence() error { a.startupPipeline.stages[2].startedAt = time.Now() a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(2) - - // 呼叫 StartServer(內部會依序 CompleteStage(2..4)) - // Retry 情境允許 port fallback(cold start 模式) - // - // M8-4b 補丁 M-3:test hook — 單元測試可用 restartStartFn 替換 ctrl.Start, - // 避免在測試環境 spawn 真的 python server。正式環境走預設 a.ctrl.Start()。 - startFn := a.ctrl.Start - if a.restartStartFn != nil { - startFn = a.restartStartFn - } - if err := startFn(); err != nil { - // startServerV2 內已 FailStage,不需重複;err 仍 propagate 給前端讓 Retry 按鈕能 catch - return err - } - - // 階段 5:開瀏覽器(或 skip) - a.runStartupStage5() - // 階段 6 由 watcher poll sentinel file 觸發 - return nil } // GetServerStatusV2 回傳 v2 版本的完整狀態。