Files
arcrun-collector/cmd/arcrun-app/diagnostics_export.go
T
Leo 8baa9170da feat(t213): 診斷檔補 engine 區塊,補上考卷 Q2「還在不在跑」缺口
pending=0 以前只能證明「現在沒有排隊的」,證明不了「真的做完了」還是
「daemon 早就掛了沒人知道」——因為診斷檔完全沒有時間資訊。

local.engine 不發明第二套判斷法:直接借用首頁狀態列本來就在用的
collectorAlive()/collectorSyncing()/collectorFailure()/describeStatus(),
只是把這些憑據也寫進 JSON(alive/syncing/crash_looping/headline/detail/
last_sync/last_activity_at/seconds_since_last_sync)。last_error 只在
alive=false 或 crash_looping=true 時才附上,避免健康行程的開機橫幅
被誤讀成錯誤訊息。

新增 diagnostics_engine_e2e_test.go:真的建執行檔、真的用 supervisor
拉起子行程、真的 Stop() 停掉它,驗證同一組 progress 數字在兩種情境下
engine.alive 正確反映活/死(永久迴歸測試,零帳號零網路,快且穩定)。

stage(youlin 帳號)實測驗證見 system-dev/wiki/status.md:兩次真實匯出
(活著/停了)JSON 全文、四題考卷重跑 4/4。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:37:53 +08:00

270 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// diagnostics_export.go — 檢修孔的 daemon 端(t213 phase 2InkStoneCo 總管交辦,2026-08-08)。
//
// 為什麼要在這裡做,不在雲端 portal 網頁做(phase 1 調查結論):
// 舊的「匯出診斷檔給我們看」按鈕住在雲端 RAG Portal 網頁(matrix/arcrun 的
// GET /portal/data/diagnostics),在封測者的**瀏覽器**裡執行;而封測者電腦上跑的
// daemon(本檔所在的 arcrun-app)是完全獨立的另一個行程,瀏覽器對本機檔案系統零
// 存取權——那顆按鈕不管加多少雲端欄位都構不到本機資料。本檔把按鈕搬到 daemon
// 行程本體,本機這半直接讀 status.json,雲端那半改打新增的 X-Arcrun-API-Key 版
// 端點(GET /portal/daemon/diagnostics,免帳密,daemon 背景行程沒有 portal session)。
//
// 🔴 leo 08-08 三條追加規則:
// ① 首頁與診斷檔必須是同一組數字——本檔**只讀** status.json 裡 t210 已經算好的
// ProgressFailureBreakdown,不重新掃 manifest、不另算一套。
// ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄
// FailureBreakdown.Groups,不認得任何一個分類名(呼應 app.go buildProgress 的規矩)。
// ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)——
// 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤,
// 不是原始路徑,這裡直接借用,不重寫第二套。
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
collector "arcrun-rag/collector"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// diagnosticsHTTP:雲端這半只是一次 GET(只讀查詢,不佔 KV/D1 寫入額度),
// 15s 逾時給雲端冷啟動/LLM 相關端點的餘裕(比照 extract_workersai.go 的精神,
// 但這支端點不碰 AI,通常遠快於此,逾時值只是保底)。
var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second}
// localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/
// 失敗分類+讀不了的檔案樣本(basename)+t213 phase 3 補的 EngineQ2 缺口)。
type localDiagnostics struct {
DaemonVersion string `json:"daemon_version"`
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
Engine engineDiagnostics `json:"engine"` // t213 phase 3Q2:還在不在跑)
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
// SkippedSampleSkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
// 沿用 buildSkipped() 既有邏輯(basename+白話格式標籤,如「舊版報告.doc(舊版 Word)」);
// 沒有東西被略過時兩者都是零值,JSON 省略。
SkippedSample []string `json:"skipped_sample,omitempty"`
SkippedMore int `json:"skipped_more,omitempty"`
}
// engineDiagnostics 回答「這台機器現在到底還有沒有在動」
// t213 phase 32026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」——
// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」,
// 因為完全沒有任何時間資訊。)
//
// 🔴 不發明第二套判斷法:AliveSyncingCrashLoopingRestartsLastErrorHeadlineDetail
// 直接借用 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 尚未設定。
type accountDiagnostics struct {
InstanceName string `json:"instance_name"`
Host string `json:"host"`
Cloud map[string]any `json:"cloud,omitempty"`
CloudError string `json:"cloud_error,omitempty"`
}
// exportedDiagnostics=按鈕按下去存的那一份完整檔案。
type exportedDiagnostics struct {
GeneratedAt string `json:"generated_at"`
Local localDiagnostics `json:"local"`
Accounts []accountDiagnostics `json:"accounts"`
}
// fetchCloudDiagnosticsFn 是可在測試中替換的間接呼叫(比照 cloud_version.go 的
// fetchCloudVersion 慣例),讓 mergeDiagnostics 以外的 IO 邊界也能被單測頂替。
var fetchCloudDiagnosticsFn = fetchCloudDiagnostics
// fetchCloudDiagnostics 打雲端新端點 GET {cypherURL}/portal/daemon/diagnostics
// X-Arcrun-API-Key 認證,matrix/arcrun commit 93b1140)。回應原樣轉存(本身已守住
// 兩條紅線:不含內部概念/不含卡片內容,見 buildDiagnostics 註解),本函式不重新挑欄位。
func fetchCloudDiagnostics(cypherURL, apiKey string) (map[string]any, error) {
base := strings.TrimSpace(cypherURL)
if base == "" {
return nil, fmt.Errorf("這個帳號還沒設定知識庫網址")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("這個帳號還沒有連線金鑰")
}
url := strings.TrimSuffix(base, "/") + "/portal/daemon/diagnostics"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Arcrun-API-Key", apiKey)
resp, err := diagnosticsHTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("連不上你的知識庫:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusNotFound {
// 舊實例還沒有這條 route ⇒ 講人話,別讓診斷檔裡出現裸 404(比照 extract_workersai.go 慣例)
return nil, fmt.Errorf("你的知識庫還是舊版(沒有雲端診斷功能)⇒ 請到 portal 按「立即更新」重裝一次")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("雲端診斷查詢失敗(HTTP %d):%.300s", resp.StatusCode, string(body))
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("雲端回應解析失敗:%w", err)
}
return out, nil
}
// buildDiagnosticsPayload 只管 IO(讀 status.json/讀 config.json/打網路);
// 純合併邏輯抽進 mergeDiagnostics(無 IO),方便測試不必真的連網/彈存檔對話框
// 就能涵蓋「同一組數字」「分類原樣照抄」「basename-only」這幾條規則。
func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
sync := loadSyncStatus()
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 {
ad := accountDiagnostics{InstanceName: accountName(acc), Host: shortHost(acc.CypherURL)}
key := acc.APIKey
if strings.TrimSpace(key) == "" {
key = acc.Namespace // 同 collector/direct.go 的既有 fallbackapi_key 空值時退回 namespace
}
cloud, err := fetchCloudDiagnosticsFn(acc.CypherURL, key)
if err != nil {
ad.CloudError = err.Error()
} else {
ad.Cloud = cloud
}
accounts = append(accounts, ad)
}
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。
//
// leo 08-08 規則對照:
// ① 同首頁數字——ProgressFailureBreakdown 原樣接住 sync 裡 t210 已算好的值,不重算。
// ② 分類名稱只認得 collector/progress.go 的 ClassifyFailure——這裡原樣照抄
// sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。
// ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是
// filepath.Base()+白話標籤),本函式不重新處理路徑。
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, engine engineDiagnostics, accounts []accountDiagnostics) exportedDiagnostics {
now := time.Now().UTC()
// engine.AliveSyncingCrashLoopingRestartsLastErrorHeadlineDetail 是呼叫端
// 現查現答傳進來的(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,
}
if skipped != nil {
local.SkippedSample = skipped.Files
local.SkippedMore = skipped.More
}
return exportedDiagnostics{
GeneratedAt: now.Format(time.RFC3339),
Local: local,
Accounts: accounts,
}
}
// ExportDiagnostics(前端「疑難排解」按鈕呼叫):組出診斷內容 → 彈系統存檔對話框 →
// 使用者選好位置就寫檔。回傳實際寫入的路徑(前端顯示「已存到 xxx」);
// 使用者取消對話框時回傳空字串+nil(不算錯誤,不彈紅字嚇人)。
func (a *App) ExportDiagnostics() (string, error) {
payload := a.buildDiagnosticsPayload()
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return "", err
}
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "匯出診斷檔",
DefaultFilename: fmt.Sprintf("arcrun-diagnostics-%s.json", time.Now().Format("2006-01-02-15-04-05")),
})
if err != nil {
return "", err
}
if path == "" {
return "", nil // 使用者按了取消
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", err
}
return path, nil
}