package main // startup_pipeline.go — M8-4b:6 階段啟動進度 + soft/hard timeout + watcher // // TDD ground truth: // - .autoflow/04-architecture/v2/startup-pipeline.md(518 行完整規格) // - R5-E1 ~ R5-E6(AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度) // // 設計重點: // 1. 6 個階段(1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices / // 5 = open browser / 6 = wait Web UI WebSocket) // 2. soft timeout = 20 秒(每階段),emit "startup:stage-timeout" 但不中斷 // 3. hard timeout = 60 秒(總時),emit "startup:error" + 進 Error state // 4. 階段 5/6 在 AutoOpenBrowser=false 時: // - 階段 5 → status="skipped" // - 階段 6 → 不檢查 timeout(使用者必須手動點 Open in Browser 才會建立 WebSocket) // 5. 階段 6 透過 sentinel file `/.first-ws-connected` 偵測; // server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go) // 6. RestartStartupSequence binding:Retry 按鈕用,5 步驟重置整個流程 // // 1-indexed stages:陣列多配一格避免 off-by-one,stages[1] ~ stages[6] 使用。 // current sentinel 值:0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。 import ( "context" "fmt" "os" "path/filepath" "sync" "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // ----------------------------------------------------------------------- // 常量 // ----------------------------------------------------------------------- const ( startupTotalStages = 6 // 每階段 soft timeout 20 秒(用 wall clock 計時,不受 pause 影響) // 觸發後 emit "startup:stage-timeout" 提示「正在重試」但不中斷流程。 // 這是「單一階段卡太久」的保護,搭配下方 hard timeout 兩層防線。 startupSoftTimeout = 20 * time.Second // startupHardTimeout 從 R5-E1 原定 60 秒一路放寬到 300 秒(5 分鐘)。 // 理由:每階段已經有 soft timeout 提示機制(20 秒),整體 budget 不需 // 緊湊也能擋住真的卡死的情境。300 秒是「使用者點完一杯咖啡都還沒好」 // 的心理上限,這時候再 fail 才合理。 // pause 機制(Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy) // 仍維持,作為「一次性 bootstrap 完全不算 budget」的快速通道。 startupHardTimeout = 300 * time.Second startupWatcherTick = 1 * time.Second ) // startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。 // 路徑:/.first-ws-connected const startupSentinelFileName = ".first-ws-connected" // ----------------------------------------------------------------------- // Event payload // ----------------------------------------------------------------------- // StartupProgressEvent 對應 startup-pipeline.md §1.1。 // 每階段 status 變化(running / completed / failed / skipped)都會 emit 一次。 type StartupProgressEvent struct { Stage int `json:"stage"` // 1-6 TotalStages int `json:"totalStages"` // 固定 6 LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label" Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped" StartedAt int64 `json:"startedAt"` // Unix ms,該階段 startedAt } // StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。 type StartupStageTimeoutEvent struct { Stage int `json:"stage"` SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20 } // StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。 type StartupErrorEvent struct { Stage int `json:"stage"` Error string `json:"error"` Cause string `json:"cause"` // "stage-failure" | "total-timeout" } // StartupStageDetailEvent — 階段內細步進度提示,讓使用者看到當下在做什麼。 // 不影響 stage status,只是 UI 上 stage-hint 的文案來源。 // DetailKey 是 i18n key,前端查表顯示;ElapsedSeconds > 0 時附在文案後當耗時提示。 type StartupStageDetailEvent struct { Stage int `json:"stage"` DetailKey string `json:"detailKey"` // i18n key, e.g. "startup.stage.3.detail.waitHealth" ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時 } // StartupStageSnapshot 是單一 stage 的 snapshot(給前端追上歷史狀態用)。 type StartupStageSnapshot struct { Stage int `json:"stage"` Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped" StartedAt int64 `json:"startedAt"` } // StartupSnapshot 是整個 pipeline 當前狀態的 snapshot。 // 前端 init 完成後呼叫 GetStartupSnapshot() 拉一次,補上 race window 中漏掉的 // progress events,避免畫面顯示「Stage 1 等待中、Stage 2 完成」這種亂序。 type StartupSnapshot struct { Current int `json:"current"` // -1 / 0 / 1-6 / 7 (ready) TotalStages int `json:"totalStages"` // 固定 6 Stages []StartupStageSnapshot `json:"stages"` // 1-indexed but slice 0-based (len=6) } // ----------------------------------------------------------------------- // stageState — 單一階段的內部狀態 // ----------------------------------------------------------------------- type stageState struct { status string // "pending" | "running" | "completed" | "failed" | "skipped" startedAt time.Time completedAt time.Time softTimeoutEmitted bool } // ----------------------------------------------------------------------- // StartupPipeline — 主 struct // ----------------------------------------------------------------------- // StartupPipeline 管理 6 階段啟動流程。 // 與 ServerController 解耦:StartupPipeline 只 emit Wails event, // 它對 server lifecycle 的影響由 ServerController(透過 emitError → setState)負責。 type StartupPipeline struct { app *App mu sync.Mutex stages [startupTotalStages + 1]stageState // 1-indexed current int // 0=未啟動、1-6=進行中、7=ready、-1=failed startedAt time.Time // Hard timeout pause 機制(首次 Python bootstrap 專用)。 // // R5-E1 的 60 秒 hard timeout 預算是針對「日常啟動」,不含首次安裝 Python // runtime(解壓 ~15MB tarball + 建 venv + pip install 9 個 wheel 含 numpy / // opencv 合計 ~150MB)這種一次性 bootstrap 工作——那可能花 2-5 分鐘。 // // 解法:讓 app.go 在真正需要首次 bootstrap 時(pythonBin 不存在)呼叫 // PauseHardTimeout(),完成後呼叫 ResumeHardTimeout()。暫停期間不算進 // sinceTotal,避開 hard timeout。Soft timeout 繼續照常(每階段 20 秒的 // 視覺提示不受影響,使用者仍會看到「正在重試...」hint)。 // // 只暫停 hard timeout,不暫停整體時鐘——因為 soft timeout 的語意是 // 「單一階段停滯太久」,首次 bootstrap 確實會停滯,該提示就該提示。 pausedDuration time.Duration // 累積暫停總時 pauseStartedAt time.Time // 當前暫停開始時間;zero = 未暫停 // watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。 watcherCancel context.CancelFunc watcherDone chan struct{} } // NewStartupPipeline 建立新的 pipeline。並未啟動 watcher,必須呼叫 Start。 func NewStartupPipeline(app *App) *StartupPipeline { return &StartupPipeline{ app: app, current: 0, } } // Start 啟動整個 pipeline,從階段 1 開始 emit running,並開啟 watcher goroutine。 // 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。 func (p *StartupPipeline) Start(ctx context.Context) { p.mu.Lock() p.startedAt = time.Now() p.current = 1 p.stages[1].status = "running" p.stages[1].startedAt = time.Now() p.mu.Unlock() p.emitProgress(1) watcherCtx, cancel := context.WithCancel(ctx) p.watcherCancel = cancel p.watcherDone = make(chan struct{}) go p.watcher(watcherCtx) } // CompleteStage 標記 stage 為 completed,並進入下一階段(若還有)。 // 若 stage == startupTotalStages → 觸發 markReady。 // // 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore,避免被 race condition 害到。 func (p *StartupPipeline) CompleteStage(stage int) { p.mu.Lock() if p.current != stage || p.current <= 0 { p.mu.Unlock() return } p.stages[stage].status = "completed" p.stages[stage].completedAt = time.Now() p.mu.Unlock() p.emitProgress(stage) if stage == startupTotalStages { p.markReady() return } // 進入下一階段 p.mu.Lock() next := stage + 1 p.current = next p.stages[next].status = "running" p.stages[next].startedAt = time.Now() p.mu.Unlock() p.emitProgress(next) } // SkipStage 標記 stage 為 skipped,並進入下一階段。 // 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。 // Watcher 看到 status=skipped → 不檢查 soft timeout(也不檢查 hard timeout)。 func (p *StartupPipeline) SkipStage(stage int) { p.mu.Lock() if p.current != stage || p.current <= 0 { p.mu.Unlock() return } p.stages[stage].status = "skipped" p.stages[stage].completedAt = time.Now() p.mu.Unlock() p.emitProgress(stage) if stage == startupTotalStages { p.markReady() return } p.mu.Lock() next := stage + 1 p.current = next p.stages[next].status = "running" p.stages[next].startedAt = time.Now() p.mu.Unlock() p.emitProgress(next) } // PauseHardTimeout 暫停 hard timeout 計時。 // 用於首次 Python bootstrap(可能花 2-5 分鐘解壓 + pip install)期間。 // 重複呼叫安全:第二次呼叫會先 Resume 再 Pause,避免累積偏差。 // 呼叫者:app.go ensureBundledPython 的「真正 bootstrap」路徑。 func (p *StartupPipeline) PauseHardTimeout() { p.mu.Lock() defer p.mu.Unlock() // 已在暫停 → 先結束前一次暫停區間再開新的(保持精確) if !p.pauseStartedAt.IsZero() { p.pausedDuration += time.Since(p.pauseStartedAt) } p.pauseStartedAt = time.Now() } // ResumeHardTimeout 結束 hard timeout 暫停。 // 若未處於暫停狀態則 no-op(不回 error,避免上層要做空檢查)。 func (p *StartupPipeline) ResumeHardTimeout() { p.mu.Lock() defer p.mu.Unlock() if p.pauseStartedAt.IsZero() { return } p.pausedDuration += time.Since(p.pauseStartedAt) p.pauseStartedAt = time.Time{} } // effectiveSinceTotal 回傳扣除暫停時間後的總時。watcher 用這個判斷 hard timeout。 // 呼叫者必須已持有 p.mu。 func (p *StartupPipeline) effectiveSinceTotalLocked() time.Duration { total := time.Since(p.startedAt) - p.pausedDuration if !p.pauseStartedAt.IsZero() { // 目前正在暫停中 → 扣掉當前暫停區間 total -= time.Since(p.pauseStartedAt) } if total < 0 { total = 0 } return total } // FailStage 標記 stage 為 failed,pipeline 停止並進 Error state。 // // 副作用(透過 emitError): // - emit "startup:error" Wails event // - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change) // - 發 OS 通知(R5-D1) func (p *StartupPipeline) FailStage(stage int, err error) { p.mu.Lock() if p.current <= 0 { p.mu.Unlock() return } p.stages[stage].status = "failed" p.current = -1 p.mu.Unlock() p.emitProgress(stage) p.emitError(stage, err, "stage-failure") p.stopWatcher() } // markReady 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。 func (p *StartupPipeline) markReady() { p.mu.Lock() p.current = startupTotalStages + 1 p.mu.Unlock() if p.app != nil && p.app.ctx != nil { // 同步 emit(成功路徑沒有 IPC backlog 風險) wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil) } p.stopWatcher() } // emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。 // 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者(CompleteStage / SkipStage 等)。 func (p *StartupPipeline) emitProgress(stage int) { p.mu.Lock() if stage <= 0 || stage > startupTotalStages { p.mu.Unlock() return } st := p.stages[stage] p.mu.Unlock() if p.app == nil || p.app.ctx == nil { return } go func() { wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{ Stage: stage, TotalStages: startupTotalStages, LabelKey: fmt.Sprintf("startup.stage.%d.label", stage), Status: st.status, StartedAt: st.startedAt.UnixMilli(), }) }() } // Snapshot 回傳 pipeline 當前所有 stages 狀態,供前端 init 完成後追上歷史。 // 解 race:Wails app 啟動到前端 EventsOn 掛上去之間,Go 端可能已經 emit // 多個 progress events 被丟掉。前端應該在 init 完成後呼叫一次此函式,把 // 已經發生但前端漏掉的 stage 狀態補上。 func (p *StartupPipeline) Snapshot() StartupSnapshot { p.mu.Lock() defer p.mu.Unlock() stages := make([]StartupStageSnapshot, 0, startupTotalStages) for i := 1; i <= startupTotalStages; i++ { stages = append(stages, StartupStageSnapshot{ Stage: i, Status: p.stages[i].status, StartedAt: p.stages[i].startedAt.UnixMilli(), }) } return StartupSnapshot{ Current: p.current, TotalStages: startupTotalStages, Stages: stages, } } // EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到 // 「當下在做什麼」。不改 stage status,只更新 UI 上的 stage-hint 欄位。 // // 呼叫點:server_control.go 裡的 startServerV2 會在 spawn binary / 等 health // check / 30 秒後 slow hint 等節點呼叫這個函式,對應到 i18n key: // startup.stage.3.detail.spawn # 正在啟動 server 子程序 // startup.stage.3.detail.waitHealth # 正在等 server 健康檢查通過 // startup.stage.3.detail.waitHealthSlow # 首次啟動 Defender 掃描可能需時 1-2 分鐘 // // elapsedSeconds > 0 時前端會在文案後顯示已等時長。 func (p *StartupPipeline) EmitStageDetail(stage int, detailKey string, elapsedSeconds int) { if p == nil || p.app == nil || p.app.ctx == nil { return } go func() { wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-detail", StartupStageDetailEvent{ Stage: stage, DetailKey: detailKey, ElapsedSeconds: elapsedSeconds, }) }() } // emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。 // cause 取值:"stage-failure" | "total-timeout" func (p *StartupPipeline) emitError(stage int, err error, cause string) { if p.app == nil { return } if p.app.ctx != nil { go func() { wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{ Stage: stage, Error: err.Error(), Cause: cause, }) }() } // 同步通知 ServerController 進 Error state if p.app.ctrl != nil { p.app.ctrl.setState(ServerStateError, err.Error()) } // R5-D1:發 OS 通知(fire-and-forget) go sendCrashNotification( "visionA Local — 啟動失敗", fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()), ) } // watcher 每秒 tick: // 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6) // 2. 檢查 hard timeout(總時 > 60 s)→ FailStage + emitError(total-timeout) // 3. 檢查 soft timeout(單一階段 > 20 s)→ emit "startup:stage-timeout" // // skip timeout 規則: // - 該階段已 skipped → 完全不檢查 soft / hard // - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard // // 退出條件:ctx.Done()、current 已不在 1-6 範圍(ready 或 failed)。 func (p *StartupPipeline) watcher(ctx context.Context) { defer close(p.watcherDone) ticker := time.NewTicker(startupWatcherTick) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: p.mu.Lock() if p.current <= 0 || p.current > startupTotalStages { p.mu.Unlock() return } cur := p.current st := p.stages[cur] curStatus := st.status sinceStage := time.Since(st.startedAt) // Hard timeout 走扣除暫停時間的 effective 時鐘,soft timeout 照常 // 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者 // 仍會看到 20 秒「正在重試」hint,但不會在 60 秒時被強制 fail。 sinceTotal := p.effectiveSinceTotalLocked() softEmitted := st.softTimeoutEmitted p.mu.Unlock() // 階段 6:每次 tick 檢查 sentinel file if cur == 6 { if p.checkSentinelFile() { p.CompleteStage(6) return } } // skip timeout 判斷 skipTimeout := false if curStatus == "skipped" { skipTimeout = true } if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser { skipTimeout = true } // hard timeout(總時 > 60 s) if !skipTimeout && sinceTotal > startupHardTimeout { err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout) // 直接 mark failed + emit 兩個 event,不走 FailStage 因為 cause 不一樣 p.mu.Lock() p.stages[cur].status = "failed" p.current = -1 p.mu.Unlock() p.emitProgress(cur) p.emitError(cur, err, "total-timeout") p.stopWatcher() return } if skipTimeout { continue } // soft timeout(單一階段 > 20 s) if sinceStage > startupSoftTimeout && !softEmitted { p.mu.Lock() p.stages[cur].softTimeoutEmitted = true p.mu.Unlock() if p.app != nil && p.app.ctx != nil { stageRef := cur go func() { wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{ Stage: stageRef, SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()), }) }() } } } } } // checkSentinelFile 檢查 /.first-ws-connected 是否存在。 // 存在 → 階段 6 完成(server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上) func (p *StartupPipeline) checkSentinelFile() bool { if p.app == nil || p.app.dataDir == "" { return false } path := filepath.Join(p.app.dataDir, startupSentinelFileName) _, err := os.Stat(path) return err == nil } // stopWatcher 主動取消 watcher goroutine。重複呼叫安全(cancel 之後再 cancel 是 no-op)。 func (p *StartupPipeline) stopWatcher() { if p.watcherCancel != nil { p.watcherCancel() } } // HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。 // // M8-4b 補丁(M-1 修復):startInternal 用這個判斷「pipeline 是否已經 FailStage 過」, // 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個 // 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。 // // 語義:current == -1(整個 pipeline 已停在 failed)或任一 stage status == "failed"。 func (p *StartupPipeline) HasFailedStage() bool { p.mu.Lock() defer p.mu.Unlock() if p.current == -1 { return true } for i := 1; i <= startupTotalStages; i++ { if p.stages[i].status == "failed" { return true } } return false } // IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。 // // M8-4b 補丁(M-2 修復):startInternal 的 R5-D3 openBrowser 邏輯用這個判斷 // 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook // 負責 openBrowser,startInternal 跳過避免瀏覽器開兩次(Linux 會開兩個 tab)。 // // 非冷啟動場景: // - pipeline == nil(單測或 Wails 未初始化) // - current == 0(尚未 Start) // - current == 7(已 ready,屬於 RestartServer 直接 Start 的情境) // - current == -1(已 failed) // // 這些場景下 startInternal 仍需要自己呼叫 openBrowser。 func (p *StartupPipeline) IsInColdStart() bool { p.mu.Lock() defer p.mu.Unlock() return p.current >= 1 && p.current <= startupTotalStages } // removeSentinelFile 移除 sentinel file。失敗時不回 error(檔案不存在是正常情況)。 // 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。 func removeSentinelFile(dataDir string) { if dataDir == "" { return } path := filepath.Join(dataDir, startupSentinelFileName) _ = os.Remove(path) }