Files
arcrun-collector/cmd/arcrun-app/diagnostics_export.go
T
Leo 0c6fb43117 fix(t213): 診斷檔遮蔽引擎錯誤訊息裡的本機絕對路徑
engine.detail/engine.last_error 在同步引擎沒在跑時,會把 collector 開機
橫幅的完整原文帶出來(direct.go RunDirect 的「監看 %s → %s」那行),其中
含監看資料夾的絕對路徑(如 /Users/xxx/Desktop/...)。這是上一輪(第二/
三輪)已發現但標記「附帶發現、未修」的洞——首頁在同一狀態下本來就會顯示
同一句話(不動,留給另案判斷),但診斷檔現在會被匯出成檔案交給外部人看,
風險層級跟留在托盤畫面上不同,這輪把它遮掉。

新增 redactLocalPaths(),只在 buildDiagnosticsPayload() 組裝 engine 欄位時
套用:把訊息裡看起來像本機絕對路徑的片段換成「…/<路徑最後一截>」,
http(s) URL 先放行原文(避免 URL 裡的 / 被誤判成本機路徑)。只動
diagnostics_export.go 這一端,不改 describeStatus()/collectorFailure()/
direct.go 的訊息本身——那些同時是首頁托盤畫面在用的同一組憑據。

擴充 diagnostics_engine_e2e_test.go(真執行檔+真 supervisor 子行程,零
網路零帳號):監看資料夾改用真實絕對路徑,證明子行程停擺後整份匯出 JSON
找不到該路徑,同時 last_error/detail 仍讀得出「引擎沒在跑、以及為什麼」
(含監看資料夾的名字)。另加 8 個 redactLocalPaths 純函式單測。

go build/go vet/go test ./...(collector+arcrun-app+supervisor 三個
package)全綠;diagnostics_stage_manual_test.go 維持預設 SKIP,本輪未碰
網路/stage。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:36:20 +08:00

411 lines
20 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()+白話標籤,
// 不是原始路徑,這裡直接借用,不重寫第二套。
// ④(t213 phase 408-08 補)engine.detailengine.last_error 一樣不准出現本機絕對
// 路徑——同③的理由,只是這次的路徑來源是 supervisor 捕到的 stderr 末行(collector
// 啟動時印的「監看 <資料夾路徑>」那行),不是檔名。做法見 redactLocalPaths():換成
// 「…/<路徑最後一截>」,不是整句吃掉——「引擎沒在跑、以及為什麼」仍要答得出來。
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,
// t213 phase 4leo 08-08 追加):headlinedetail 沿用首頁同一套 describeStatus()
// 但 detail 在 !alive 時會把 collectorFailure() 的死因原文接進來(見 describeStatus
// 的「原因:」那幾行)——那句死因常常就是 supervisor 捕到的 stderr 末行,可能帶著
// 監看資料夾的本機絕對路徑(見下面 redactLocalPaths 的長註解)。這裡只遮蔽「要匯出
// 成檔案」的這一份,describeStatus() 本身/首頁托盤畫面完全不動。
Headline: redactLocalPaths(headline),
Detail: redactLocalPaths(detail),
}
engine.LastError = redactLocalPaths(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 ""
}
// redactLocalPaths — t213 phase 4leo 08-08 追加規則④)。
//
// 背景:`engine.detail``engine.last_error` 的內容有一部分來自 collectorFailure() 的
// 死因原文,那句話是 supervisor 捕到的 stderr **末行**(見 supervisor.go runOnce 的
// stderr scanner)——常見的情況是子行程剛啟動就掛,最後一行 stderr 停在
// direct.go RunDirect 印的開機橫幅:「collector direct daemon 啟動:監看 <資料夾絕對路徑>
// → <目的地>」,也可能是任何 os.PathError 形狀的訊息(如「open /Users/x/y: permission
// denied」)。兩種形狀都以本機絕對路徑開頭——這條路徑會洩漏使用者的目錄結構(含帳號名,
// 例如 /Users/<帳號>/... 或 C:\Users\<帳號>\...),跟既有的「失敗檔名只出 basename」
// (見 buildSkipped())是同一個理由,只是換了個欄位發作。
//
// 怎麼遮:只挖掉「路徑本體」,換成「…/<路徑最後一截>」——資料夾/檔名本身保留,
// 訊息的其餘文字原封不動。這樣「引擎沒在跑、以及為什麼」依然讀得懂(不能把資訊遮成啞巴),
// 只是看不出這是使用者電腦上的哪個帳號、哪層目錄。
//
// 只在這裡(診斷檔匯出的最後一哩)做,不改 direct.gosupervisor.go 的訊息本身,也不改
// describeStatus()collectorFailure():那兩支同時也是首頁托盤畫面在用的同一組憑據,
// 動了會連首頁畫面一起變——超出本次「只動診斷檔這一端」的範圍(首頁要不要一起改,
// 留給總管/leo 另外決定,見交接紀錄)。
func redactLocalPaths(s string) string {
if s == "" {
return s
}
r := []rune(s)
n := len(r)
var b strings.Builder
for i := 0; i < n; {
// http(s) URL 先放行,原文照抄——避免其中的 `/`(如 .../webhooks/named/...
// 被下面的「本機路徑」判斷誤吃(URL 不是本機目錄結構,不在遮蔽範圍內)。
if j, ok := urlSpanAt(r, i); ok {
b.WriteString(string(r[i:j]))
i = j
continue
}
if plen := absPathPrefixLen(r, i); plen > 0 {
start := i
j := i + plen
for j < n && !isPathBoundary(r[j]) {
j++
}
b.WriteString("…/")
b.WriteString(lastPathSegment(string(r[start:j])))
i = j
continue
}
b.WriteRune(r[i])
i++
}
return b.String()
}
// urlSpanAt 偵測 i 處是不是 http(s) URL 的起點(必須在字詞邊界上,避免咬到中間),
// 回傳 URL 一路唸到下一個 URL 邊界字元為止的結尾位置。
//
// 🔴 這裡不能直接用 isPathBoundary 當終止點——URL 裡的 `://` scheme 分隔符與
// `:port` 都合法含有冒號,若拿冒號當終止點,`https://` 掃到第一個 `:` 就會被腰斬成
// 「https」,剩下的 `//host/path` 又會被 absPathPrefixLen 誤判成本機路徑(因為冒號
// 本身是 isPathBoundary 認得的邊界字元,讓緊接在後面的 `/` 通過起點檢查)——
// 這正是這支函式存在的理由,用專屬的終止點集合(isPathBoundary 拿掉冒號)。
func urlSpanAt(r []rune, i int) (int, bool) {
if i > 0 && !isPathBoundary(r[i-1]) {
return 0, false
}
rest := string(r[i:min(i+8, len(r))])
if !strings.HasPrefix(rest, "http://") && !strings.HasPrefix(rest, "https://") {
return 0, false
}
j := i
for j < len(r) && !isURLBoundary(r[j]) {
j++
}
return j, true
}
// isURLBoundaryisPathBoundary 拿掉冒號(URL 合法含冒號,見上面 urlSpanAt 的註解)。
func isURLBoundary(r rune) bool {
if r == ':' {
return false
}
return isPathBoundary(r)
}
// absPathPrefixLen 判斷 i 是不是一個本機絕對路徑的起點,回傳「路徑根部」本身的字元數
// Unix `/` 算 1 個;Windows 磁碟代號 `C:\``C:/` 算 3 個)。只在「前一個字元是邊界
// 字元(或 i==0)」時才算數——避免把一般文字裡湊巧出現的字元誤判成路徑起點。
func absPathPrefixLen(r []rune, i int) int {
if i > 0 && !isPathBoundary(r[i-1]) {
return 0
}
if r[i] == '/' {
return 1
}
if i+2 < len(r) && isASCIILetter(r[i]) && r[i+1] == ':' && (r[i+2] == '\\' || r[i+2] == '/') {
return 3
}
return 0
}
func isASCIILetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
// isPathBoundary=這個字元不可能是路徑本體的一部分,可以拿來當「路徑到這裡結束」的判準
// (空白、中西文標點、箭頭)。本檔目前已知的兩種訊息形狀都靠它正確截斷:
// 「監看 A、B → url」用「、」「→」收尾;`os.PathError` 的「path: 原因」用「:」收尾。
// 刻意不把純空白以外的一般文字字元(含資料夾名裡常見的空格)當終止點以外的東西處理——
// 資料夾名稱裡的空格會讓比對在空格處提早停下,不會吃到後面不相干的文字,這是刻意的保守
// 選擇:寧可少遮一點(漏出資料夾名稱片段),也不要多遮(把後面的說明文字一起吃掉)。
func isPathBoundary(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r',
'、', '', '', '(', ')',
',', ';', '', '', ':', '→',
'"', '\'', '<', '>':
return true
}
return false
}
// lastPathSegment 取路徑最後一截(跨 `/`、`\` 兩種分隔符,不假設執行平台),
// 拿不到任何一截(例如整條路徑只有根目錄)時回一個看得懂的佔位字,不留空字串。
func lastPathSegment(p string) string {
p = strings.TrimRight(p, `/\`)
if idx := strings.LastIndexAny(p, `/\`); idx >= 0 {
p = p[idx+1:]
}
if p == "" {
return "本機路徑"
}
return p
}
// 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
}