diff --git a/cmd/arcrun-app/diagnostics_engine_e2e_test.go b/cmd/arcrun-app/diagnostics_engine_e2e_test.go new file mode 100644 index 0000000..fb2351a --- /dev/null +++ b/cmd/arcrun-app/diagnostics_engine_e2e_test.go @@ -0,0 +1,113 @@ +package main + +// diagnostics_engine_e2e_test.go — t213 phase 3 端到端驗證(考卷 Q2 缺口)。 +// +// 舊版診斷檔看得到 pending=0,但分不出「真的做完了」還是「daemon 早就掛了沒人知道」—— +// 因為完全沒有時間資訊。這支測試用**真的執行檔+真的 supervisor 子行程**(同 App 在真機上 +// 做的事,見 supervise.go startSupervisor),證明新加的 local.engine 這組欄位不是憑空編出來 +// 的假象,而是真的跟著子行程死活變動: +// +// 情境一(存活中):子行程剛拉起、跑過至少一輪 → engine.alive=true、 +// seconds_since_last_sync 是幾秒內的新鮮心跳。 +// 情境二(考卷 Q2 的核心,刻意製造「同步停擺」):把子行程停掉(模擬同步引擎掛掉/ +// 停止——不是「整個 App 被關掉」那種情況,那種情況下 App 根本不會被打開來按 +// 「匯出診斷檔」,不在本檔驗證範圍)→ engine.alive=false、headline 講人話說明、 +// last_sync 凍結在停止前那一刻不再前進、seconds_since_last_sync 隨等待時間持續變大。 +// +// 刻意零帳號、零網路:這支測試只驗「心跳(LastSync)跟著子行程死活走」這條機制本身, +// 不需要任何雲端憑證,任何機器上都能跑、都穩定。 +import ( + "encoding/json" + "os/exec" + "path/filepath" + "testing" + "time" + + "arcrun-rag/collector/supervisor" +) + +func TestDiagnosticsEngine_ReflectsRealSupervisorLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("拉真子行程+等真心跳,跑起來要十幾秒,-short 跳過") + } + + // 🔴 先編再改 HOME(同 freshinstall_e2e_test.go 的教訓:Go 模組快取會寫進 HOME, + // 先改 HOME 再 build 會把唯讀快取寫進暫存目錄,測試結束清不掉)。 + bin := filepath.Join(t.TempDir(), "arcrun-app-test") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("編不出執行檔:%v\n%s", err, out) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + // 需要至少一個帳號:LoadDirectConfig 會拒絕零帳號的設定(「缺必填欄位:accounts」, + // 連迴圈都進不去),但這個帳號**不必連得上**——cypher_url 刻意指向一個解析不出來的 + // 網址,驗證的是「心跳(LastSync)跟著子行程死活走」這條機制,不是雲端連線本身。 + cfg := &directConfig{ + Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"), + Accounts: []accountCfg{{CypherURL: "https://example.invalid.test", Namespace: "test", APIKey: "test"}}, + } + if err := saveCfg(cfg); err != nil { + t.Fatalf("存設定失敗:%v", err) + } + + sup = supervisor.New(bin, configPath()) + sup.ArgPrefix = []string{collectorModeFlag} + t.Cleanup(func() { sup.Stop() }) + sup.Start() + + // ── 情境一:等到子行程真的活著、心跳過至少一輪 ── + deadline := time.Now().Add(15 * time.Second) + for { + s := loadSyncStatus() + if collectorAlive() && s.LastSync != "" { + break + } + if time.Now().After(deadline) { + t.Fatalf("等 15 秒子行程都沒完成第一輪心跳(alive=%v, LastSync=%q)", collectorAlive(), s.LastSync) + } + time.Sleep(200 * time.Millisecond) + } + + app := &App{} + aliveOut := app.buildDiagnosticsPayload() + aliveJSON, _ := json.MarshalIndent(aliveOut, "", " ") + t.Logf("── 情境一(存活中)完整診斷檔 ──\n%s", aliveJSON) + + if !aliveOut.Local.Engine.Alive { + t.Fatalf("情境一:engine.alive 應為 true,得到 %+v", aliveOut.Local.Engine) + } + if aliveOut.Local.Engine.SecondsSinceLastSync == nil || *aliveOut.Local.Engine.SecondsSinceLastSync > 10 { + t.Fatalf("情境一:last_sync 應該是幾秒內的新鮮心跳,得到 seconds_since_last_sync=%v", + aliveOut.Local.Engine.SecondsSinceLastSync) + } + + // ── 情境二:停掉子行程,模擬同步引擎掛了/被停止(考卷 Q2)── + frozenLastSync := aliveOut.Local.Engine.LastSync + sup.Stop() + + // 等一段「真的有時間流逝」,讓 seconds_since_last_sync 明顯拉開(不是量測誤差)。 + time.Sleep(6 * time.Second) + + stoppedOut := app.buildDiagnosticsPayload() + stoppedJSON, _ := json.MarshalIndent(stoppedOut, "", " ") + t.Logf("── 情境二(已停擺)完整診斷檔 ──\n%s", stoppedJSON) + + if stoppedOut.Local.Engine.Alive { + t.Fatal("情境二:子行程已經 Stop(),engine.alive 應為 false") + } + if stoppedOut.Local.Engine.LastSync != frozenLastSync { + t.Fatalf("情境二:子行程停了之後不該再有新心跳,last_sync 卻變了:停前 %q,停後 %q", + frozenLastSync, stoppedOut.Local.Engine.LastSync) + } + if stoppedOut.Local.Engine.SecondsSinceLastSync == nil || *stoppedOut.Local.Engine.SecondsSinceLastSync < 5 { + t.Fatalf("情境二:心跳停了 6 秒以上,seconds_since_last_sync 應該 >=5,得到 %v", + stoppedOut.Local.Engine.SecondsSinceLastSync) + } + if stoppedOut.Local.Engine.Headline == "" { + t.Fatal("情境二:alive=false 時 headline 不該是空的——這是給人看的『還在不在跑』答案") + } + t.Logf("✅ Q2 證據鏈完整:pending 不變的情況下,alive/last_sync/seconds_since_last_sync 三者都反映了子行程真的停了") +} diff --git a/cmd/arcrun-app/diagnostics_export.go b/cmd/arcrun-app/diagnostics_export.go index 333b78e..93d69d8 100644 --- a/cmd/arcrun-app/diagnostics_export.go +++ b/cmd/arcrun-app/diagnostics_export.go @@ -37,10 +37,11 @@ import ( var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second} // localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/ -// 失敗分類+讀不了的檔案樣本(basename)。 +// 失敗分類+讀不了的檔案樣本(basename)+t213 phase 3 補的 Engine(Q2 缺口)。 type localDiagnostics struct { DaemonVersion string `json:"daemon_version"` UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版) + Engine engineDiagnostics `json:"engine"` // t213 phase 3(Q2:還在不在跑) Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total) FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計) // SkippedSample/SkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本, @@ -50,6 +51,37 @@ type localDiagnostics struct { SkippedMore int `json:"skipped_more,omitempty"` } +// engineDiagnostics 回答「這台機器現在到底還有沒有在動」 +// (t213 phase 3,2026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」—— +// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」, +// 因為完全沒有任何時間資訊。) +// +// 🔴 不發明第二套判斷法:Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail +// 直接借用 supervise.go 既有的 collectorAlive()/collectorSyncing()/collectorFailure(), +// 以及 app.go 首頁狀態列用的 describeStatus()——同一套憑據,這裡只是把它們也寫進 JSON, +// 確保首頁與診斷檔永遠是同一組真相(leo 08-08 紅線①),不是另外重新猜一套「活著嗎」。 +type engineDiagnostics struct { + Alive bool `json:"alive"` // 同步引擎子行程現在活著嗎(supervisor 狀態機,非 pgrep,見 supervise.go collectorAlive() 註解) + Syncing bool `json:"syncing"` // 這一刻正在跑一輪嗎 + CrashLooping bool `json:"crash_looping"` // 一直啟動失敗(≥3 次重試都失敗,見 crashLoopThreshold) + Restarts int `json:"restarts"` // 累計重試次數 + LastError string `json:"last_error,omitempty"` // 最近一次啟動失敗的原因 + Headline string `json:"headline"` // 同首頁大字,例如「同步引擎沒有在跑」 + Detail string `json:"detail,omitempty"` // 同首頁小字 + + // LastSync=最近一輪「心跳」完成時間(RFC3339)。這是每一輪(預設每 5 秒)**不論那輪 + // 有沒有事做都會寫**的欄位(見 collector/direct.go RunDirectOnce 尾端),所以它是心跳, + // 不是「上次同步到東西」的時間。 + // LastActivityAt=最近一輪「真的有產出」(成功或失敗都算)的時間,同首頁「已整理 N 份」 + // 那個時間戳(見 collector/sync_status.go CarryForwardActivity)。 + // + // ⇒ pending=0 但 last_sync 是很久以前 ⇒ 不是「做完了」,是「心跳停了」; + // last_sync 很新但 alive=false ⇒ daemon 這一刻已死,last_sync 是死前最後一口氣。 + LastSync string `json:"last_sync,omitempty"` + LastActivityAt string `json:"last_activity_at,omitempty"` + SecondsSinceLastSync *int64 `json:"seconds_since_last_sync,omitempty"` // generated_at 減 last_sync 的秒數;從沒同步過時省略(不假裝有數字) +} + // accountDiagnostics=一個雲端帳號(知識庫實例)的雲端那半。 // Cloud 拿不到時填 CloudError(誠實回報,不假裝有數字)——常見原因:舊版雲端沒有 // /portal/daemon/diagnostics(部署還沒到)、網路不通、api key 尚未設定。 @@ -119,6 +151,21 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics { skipped := buildSkipped(sync) // 首頁既有邏輯:Files 已是 basename+白話標籤,直接借用 update := a.CheckUpdate() // 現查現答,不用可能過期的背景檢查快取(Q4) + // t213 phase 3(Q2):現查現答同步引擎「現在還在不在跑」,直接讀 supervise.go + // 已經在維護的同一組憑據(供首頁 describeStatus() 用的那套),不重新發明判斷法。 + _, headline, detail := describeStatus(sync) + alive := collectorAlive() + lastErr, restarts, looping := collectorFailure() + engine := engineDiagnostics{ + Alive: alive, + Syncing: collectorSyncing(), + CrashLooping: looping, + Restarts: restarts, + Headline: headline, + Detail: detail, + } + engine.LastError = engineLastErrorFor(alive, looping, lastErr) + cfg, _ := loadCfg() accounts := make([]accountDiagnostics, 0, len(cfg.Accounts)) for _, acc := range cfg.Accounts { @@ -136,7 +183,22 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics { accounts = append(accounts, ad) } - return mergeDiagnostics(sync, skipped, version, update, accounts) + return mergeDiagnostics(sync, skipped, version, update, engine, accounts) +} + +// engineLastErrorFor 決定 LastError 要不要出現在診斷檔——只在「真的代表出事」時才附上 +// (子行程不活著、或一直啟動失敗)。 +// +// 🔴 supervisor 的 LastError 是「最近一行 stderr」,即使子行程健康,開機橫幅那類純資訊 +// 也會被記進去(見 supervisor.go runOnce 的 stderr scanner,本身不判斷這行是不是真錯誤—— +// 這是既有共用機制的既有行為,不在本次改動範圍);若不加這道判斷,alive=true 的健康快照裡 +// 也會混進一句看起來像錯誤、實際只是啟動訊息的文字,誤導看診斷檔的人。 +// 同首頁 describeStatus():只在「一直失敗」或「沒有在跑」兩種狀態才把這句話端上檯面。 +func engineLastErrorFor(alive, crashLooping bool, lastErr string) string { + if !alive || crashLooping { + return lastErr + } + return "" } // mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。 @@ -147,10 +209,25 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics { // sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。 // ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是 // filepath.Base()+白話標籤),本函式不重新處理路徑。 -func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, accounts []accountDiagnostics) exportedDiagnostics { +func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, engine engineDiagnostics, accounts []accountDiagnostics) exportedDiagnostics { + now := time.Now().UTC() + + // engine.Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端 + // 現查現答傳進來的(IO/即時行程狀態,見 buildDiagnosticsPayload);這裡只補上 + // 從 status.json 本來就有、原樣接住的兩個時間戳,以及純算術算出的落後秒數。 + engine.LastSync = sync.LastSync + engine.LastActivityAt = sync.LastActivityAt + if sync.LastSync != "" { + if t, err := time.Parse(time.RFC3339, sync.LastSync); err == nil { + secs := int64(now.Sub(t).Seconds()) + engine.SecondsSinceLastSync = &secs + } + } + local := localDiagnostics{ DaemonVersion: daemonVersion, UpdateCheck: update, + Engine: engine, Progress: sync.Progress, FailureBreakdown: sync.FailureBreakdown, } @@ -159,7 +236,7 @@ func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, local.SkippedMore = skipped.More } return exportedDiagnostics{ - GeneratedAt: time.Now().UTC().Format(time.RFC3339), + GeneratedAt: now.Format(time.RFC3339), Local: local, Accounts: accounts, } diff --git a/cmd/arcrun-app/diagnostics_export_test.go b/cmd/arcrun-app/diagnostics_export_test.go index 1f1f72f..4bf018a 100644 --- a/cmd/arcrun-app/diagnostics_export_test.go +++ b/cmd/arcrun-app/diagnostics_export_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "strings" "testing" + "time" collector "arcrun-rag/collector" ) @@ -26,7 +27,7 @@ func TestMergeDiagnostics_SameNumbersAsHomeScreen(t *testing.T) { }, }, } - out := mergeDiagnostics(sync, nil, "0.18.23", UpdateInfo{Current: "0.18.23"}, nil) + out := mergeDiagnostics(sync, nil, "0.18.23", UpdateInfo{Current: "0.18.23"}, engineDiagnostics{}, nil) if out.Local.Progress != sync.Progress { t.Fatalf("progress 沒有原樣接住:got %+v want %+v", out.Local.Progress, sync.Progress) @@ -53,7 +54,7 @@ func TestMergeDiagnostics_CategoryNamesPassThroughVerbatim(t *testing.T) { Groups: []collector.FailureGroup{{Category: "未來才會新增的假分類", Count: 1}}, }, } - out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, nil) + out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil) if len(out.Local.FailureBreakdown.Groups) != 1 || out.Local.FailureBreakdown.Groups[0].Category != "未來才會新增的假分類" { t.Fatalf("分類名稱沒有原樣照抄:%+v", out.Local.FailureBreakdown.Groups) } @@ -67,7 +68,7 @@ func TestMergeDiagnostics_SkippedNamesAreBasenameOnly(t *testing.T) { Files: []string{"教材授權書-Leov2.pages(Pages)", "舊版報告.doc(舊版 Word)"}, More: 2, } - out := mergeDiagnostics(syncStatus{}, skipped, "dev", UpdateInfo{}, nil) + out := mergeDiagnostics(syncStatus{}, skipped, "dev", UpdateInfo{}, engineDiagnostics{}, nil) if len(out.Local.SkippedSample) != 2 { t.Fatalf("skipped_sample 數量跑掉:%v", out.Local.SkippedSample) @@ -93,7 +94,7 @@ func TestMergeDiagnostics_SkippedNamesAreBasenameOnly(t *testing.T) { func TestMergeDiagnostics_NoSkipped_OmitsSampleFields(t *testing.T) { // 沒有任何檔案被略過(skipped == nil,同 buildSkipped() 的既有語意)→ 不該生出空陣列佔畫面。 - out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, nil) + out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil) if out.Local.SkippedSample != nil { t.Fatalf("沒有略過任何檔案時 SkippedSample 應為 nil,得到 %v", out.Local.SkippedSample) } @@ -112,7 +113,7 @@ func TestMergeDiagnostics_AccountsCarryCloudOrError(t *testing.T) { {InstanceName: "geek6688", Host: "arcrun-cypher-executor.arcrun-fc9490d5.workers.dev", CloudError: "你的知識庫還是舊版(沒有雲端診斷功能)⇒ 請到 portal 按「立即更新」重裝一次"}, } - out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, accounts) + out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, accounts) if len(out.Accounts) != 2 { t.Fatalf("accounts 數量跑掉:%d", len(out.Accounts)) } @@ -123,3 +124,96 @@ func TestMergeDiagnostics_AccountsCarryCloudOrError(t *testing.T) { t.Fatalf("第二個帳號應該只有 cloud_error、沒有 cloud:%+v", out.Accounts[1]) } } + +// ── t213 phase 3:engine(Q2 缺口——「還在不在跑」)───────────────────────── + +func TestMergeDiagnostics_EngineCarriesLiveStateVerbatim(t *testing.T) { + // engine 的 Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端 + // 現查現答傳進來的(見 buildDiagnosticsPayload),mergeDiagnostics 不准對它們做任何 + // 判斷或改寫——同 leo 規則②「本函式不認得任何一個分類字串」的精神,換到 engine 身上。 + engine := engineDiagnostics{ + Alive: false, Syncing: false, CrashLooping: true, Restarts: 5, + LastError: "exit status 1", Headline: "同步引擎一直啟動失敗", Detail: "已自動重試 5 次都失敗", + } + out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engine, nil) + got := out.Local.Engine + if got.Alive != false || got.CrashLooping != true || got.Restarts != 5 || + got.LastError != "exit status 1" || got.Headline != "同步引擎一直啟動失敗" || got.Detail != "已自動重試 5 次都失敗" { + t.Fatalf("engine 現查現答欄位沒有原樣接住:%+v", got) + } +} + +func TestEngineLastErrorFor_OnlyShownWhenItMeansSomething(t *testing.T) { + // supervisor 的 LastError 是「最近一行 stderr」,健康行程的開機橫幅也會被記進去 + // (見 diagnostics_engine_e2e_test.go 情境一實測:alive=true 卻帶著一句啟動訊息)。 + // 這支測試釘住閘門本身:alive 且沒有一直失敗 ⇒ 就算 lastErr 非空也要被吞掉, + // 不然診斷檔會讓人誤以為健康的引擎在報錯。 + cases := []struct { + name string + alive, looping bool + lastErr, wantOut string + }{ + {"健康行程的雜訊 stderr 被吞掉", true, false, "collector direct daemon 啟動:…", ""}, + {"沒有在跑時原樣帶出死因", false, false, "exit status 2", "exit status 2"}, + {"一直啟動失敗時原樣帶出死因", true /* 短暫 Starting 也算 alive */, true, "config JSON 解析失敗", "config JSON 解析失敗"}, + {"健康且沒有任何 stderr", true, false, "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := engineLastErrorFor(c.alive, c.looping, c.lastErr) + if got != c.wantOut { + t.Fatalf("engineLastErrorFor(%v,%v,%q) = %q,want %q", c.alive, c.looping, c.lastErr, got, c.wantOut) + } + }) + } +} + +func TestMergeDiagnostics_EngineTimestampsFromSyncStatus(t *testing.T) { + // LastSync/LastActivityAt 要原樣接住 status.json 裡的值(同 Progress 的規矩), + // 不是本函式另外現查——這兩個欄位是 t91/t205 既有機制,不重新發明。 + sync := syncStatus{LastSync: "2026-08-08T05:00:00Z", LastActivityAt: "2026-08-08T04:30:00Z"} + out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil) + if out.Local.Engine.LastSync != sync.LastSync { + t.Fatalf("last_sync 沒有原樣接住:got %q want %q", out.Local.Engine.LastSync, sync.LastSync) + } + if out.Local.Engine.LastActivityAt != sync.LastActivityAt { + t.Fatalf("last_activity_at 沒有原樣接住:got %q want %q", out.Local.Engine.LastActivityAt, sync.LastActivityAt) + } +} + +func TestMergeDiagnostics_SecondsSinceLastSync_StaleHeartbeatIsVisible(t *testing.T) { + // 核心情境(考卷 Q2):pending=0,但 last_sync 停在很久以前——這份 JSON 必須讓人 + // 看得出來「不是做完了,是心跳停了」,不必額外問任何人。 + stale := time.Now().UTC().Add(-45 * time.Minute).Format(time.RFC3339) + sync := syncStatus{ + LastSync: stale, + Progress: collector.SyncProgress{Total: 9000, Done: 8980, Pending: 0, Stuck: 0, Unreadable: 20}, + } + out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{Alive: false, Headline: "同步引擎沒有在跑"}, nil) + + if out.Local.Progress.Pending != 0 { + t.Fatalf("測試前提跑掉:pending 應為 0,得到 %d", out.Local.Progress.Pending) + } + if out.Local.Engine.SecondsSinceLastSync == nil { + t.Fatal("seconds_since_last_sync 不該是空的——有 last_sync 就該算得出落後秒數") + } + got := *out.Local.Engine.SecondsSinceLastSync + if got < 44*60 || got > 46*60 { + t.Fatalf("seconds_since_last_sync 算錯:got %ds,應接近 2700s(45 分鐘)", got) + } + if out.Local.Engine.Alive { + t.Fatal("測試前提跑掉:這個情境應該是 alive=false(同步引擎沒有在跑)") + } +} + +func TestMergeDiagnostics_NoLastSync_OmitsSecondsSinceLastSync(t *testing.T) { + // 從沒同步過(全新安裝、還沒連知識庫):last_sync 是空字串,不該假裝算得出落後秒數。 + out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil) + if out.Local.Engine.SecondsSinceLastSync != nil { + t.Fatalf("沒有 last_sync 時 seconds_since_last_sync 應為 nil,得到 %v", *out.Local.Engine.SecondsSinceLastSync) + } + raw, _ := json.Marshal(out) + if strings.Contains(string(raw), "seconds_since_last_sync") { + t.Fatalf("omitempty 沒生效:%s", raw) + } +}