fix(t91+t92): 萃取狀態可見+Finder 啟動 PATH 修正——背景執行不可見的兩題同根一起解

leo 07-28 實測根因:Finder 起的 GUI app 只有最小 PATH(無 /opt/homebrew/bin)
→ claude 靜默找不到→整輪萃取失敗,托盤卻顯示「看守中」。leo:「我怎麼知道它有萃?」
- FindClaudeBin:LookPath 失敗後掃 4 個常見絕對路徑,找到回寫 config claude_bin
- CheckExtractor 預檢+每輪寫 ~/.arcrun-rag/status.json(ok/fail 計數+失敗清單)
- 托盤:引擎未就緒→「⚠ 萃取引擎未就緒:<白話原因>」;失敗>0→可點開明細;正常→「已萃 N 檔」
測試:collector+supervisor+tray 三模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 12:41:41 +08:00
parent 5e122b47ec
commit 6c9d74d588
7 changed files with 595 additions and 17 deletions
+87 -9
View File
@@ -71,7 +71,8 @@ type directConfig struct {
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
Library string `json:"library,omitempty"`
Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma
Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma
ClaudeBin string `json:"claude_bin,omitempty"` // t92collector fallback 找到後回寫,下次直達
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑)
IngestWF string `json:"ingest_workflow,omitempty"`
RemovedWF string `json:"removed_workflow,omitempty"`
@@ -79,6 +80,52 @@ type directConfig struct {
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
}
// ── t91 萃取狀態可見性 ──────────────────────────────────────────────────────────
// traySyncStatus 對映 collector 寫出的 ~/.arcrun-rag/status.json(僅含托盤需要的欄位)。
type traySyncStatus struct {
LastSync string `json:"last_sync,omitempty"`
ExtractedOK int `json:"extracted_ok"`
ExtractFailed int `json:"extract_failed"`
Failures []trayExtFail `json:"failures,omitempty"`
ExtractorOK bool `json:"extractor_ok"`
ExtractorError string `json:"extractor_error,omitempty"`
}
type trayExtFail struct {
Path string `json:"path"`
Error string `json:"error"`
}
// appStatusFilePath 回傳狀態檔路徑:~/.arcrun-rag/status.json。
func appStatusFilePath() string { return filepath.Join(appDir(), "status.json") }
// loadAppSyncStatus 讀取狀態檔;檔不存在或解析失敗回零值(托盤自行降級)。
func loadAppSyncStatus() traySyncStatus {
var s traySyncStatus
data, err := os.ReadFile(appStatusFilePath())
if err != nil {
return s
}
_ = json.Unmarshal(data, &s)
return s
}
// syncStatusLabel 依萃取狀態決定要在「看守中」後面追加什麼文案。
// hasExtractorconfig 有設定 extractorclaude/gemma),才有萃取計數的語意。
func syncStatusLabel(sync traySyncStatus, hasExtractor bool) string {
if !hasExtractor {
return ""
}
if !sync.ExtractorOK && sync.ExtractorError != "" {
return "" // extractor 錯誤由 buildStatusLabel 整體處理,這裡不加
}
if sync.ExtractedOK > 0 {
return fmt.Sprintf(" · 已萃 %d 檔", sync.ExtractedOK)
}
return ""
}
// appDir 是設定與 manifest 落地處:~/.arcrun-rag/
func appDir() string {
home, _ := os.UserHomeDir()
@@ -610,6 +657,11 @@ func main() {
if !hasTray {
return
}
// t91:每次重建選單時讀最新狀態,確保看守中的萃取結果即時反映。
sync := loadAppSyncStatus()
supStatus := sup.Status()
statusItem.Label = "狀態:" + buildStatusLabel(supStatus, sync, cfg.Extractor)
// t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行);
// 有 cypher_url 時再加一行縮短的 hostfyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。
connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil)
@@ -622,7 +674,21 @@ func main() {
hostItem.Disabled = true
items = append(items, hostItem)
}
items = append(items, statusItem, fyne.NewMenuItemSeparator())
items = append(items, statusItem)
// t91:有萃取失敗時加警告項,點開顯示哪些檔出了什麼問題(白話)。
if cfg.Extractor != "" && sync.ExtractFailed > 0 {
syncSnapshot := sync // 捕獲,避免 closure 讀到更新後的值
failItem := fyne.NewMenuItem(fmt.Sprintf("⚠ 萃取失敗 %d 檔", sync.ExtractFailed), func() {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("本輪有 %d 個檔案沒有成功進知識庫:\n\n", syncSnapshot.ExtractFailed))
for _, f := range syncSnapshot.Failures {
sb.WriteString("• " + f.Path + "\n " + f.Error + "\n\n")
}
dialog.ShowInformation("萃取失敗詳情", strings.TrimSpace(sb.String()), win)
})
items = append(items, failItem)
}
items = append(items, fyne.NewMenuItemSeparator())
for _, f := range cfg.Folders() {
folder := f // capture
it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() {
@@ -673,11 +739,10 @@ func main() {
a.Lifecycle().SetOnStarted(func() { hideDockIcon() })
refreshTray := rebuildTray
// 狀態變更 → 刷新 tray 文案(回呼在 supervisor goroutine,切回 UI thread
// 狀態變更 → 刷新 tray 選單(回呼在 supervisor goroutine,切回 UI thread
// t91label 設定統一在 rebuildTray 裡做(同時讀取 status.json);
// 此版 fyne 無 fyne.Dotray label 更新直接做即可(2026-07-21 真機 build 實測修正)。
sup.SetOnChange(func(s supervisor.Status) {
// 此版 fyne 無 fyne.Dotray menu label 更新直接做即可
// 2026-07-21 真機 build 實測修正)
statusItem.Label = "狀態:" + humanStatus(s)
refreshTray()
})
@@ -703,13 +768,26 @@ func main() {
}
// humanStatus 把狀態機轉成白話(給不懂內部的人)。
// 需要考慮萃取狀態時,改用 buildStatusLabel。
func humanStatus(s supervisor.Status) string {
return buildStatusLabel(s, loadAppSyncStatus(), "")
}
// buildStatusLabel 合併 supervisor 狀態與萃取狀態,產出托盤顯示文字。
// extractor 空字串代表 config 未設定 extractor(舊制直送),此時不顯示萃取計數。
func buildStatusLabel(s supervisor.Status, sync traySyncStatus, extractor string) string {
hasExtractor := extractor != ""
switch s.State {
case supervisor.StateWatching:
if !s.LastRoundAt.IsZero() {
return fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
// t92extractor 預檢失敗時,直接換掉「看守中」文案
if hasExtractor && !sync.ExtractorOK && sync.ExtractorError != "" {
return "⚠ 萃取引擎未就緒:" + sync.ExtractorError
}
return "看守中"
base := "看守中"
if !s.LastRoundAt.IsZero() {
base = fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
}
return base + syncStatusLabel(sync, hasExtractor)
case supervisor.StateStarting:
return "啟動中…"
case supervisor.StateError:
+72
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"
"arcrun-rag/collector/supervisor"
)
// t26:暱稱 > email > 未設定(leo 07-24 拍板:CF 全程隱形,這條邏輯不該提到 cypher/CF)。
@@ -183,3 +185,73 @@ func TestApplyRemoteConfigKeepsFoldersOnSameInstance(t *testing.T) {
t.Error("同實例不應清空資料夾清單")
}
}
// ── t91/t92buildStatusLabel 與 syncStatusLabel 邏輯 ──────────────────────
// TestBuildStatusLabelNormal:看守中(無 extractor 設定)→ 正常「看守中」文案。
func TestBuildStatusLabelNormal(t *testing.T) {
s := supervisor.Status{State: supervisor.StateWatching}
got := buildStatusLabel(s, traySyncStatus{ExtractorOK: true}, "")
if !strings.HasPrefix(got, "看守中") {
t.Errorf("got=%q,應以「看守中」開頭", got)
}
if strings.Contains(got, "萃取") {
t.Errorf("無 extractor 時不應有萃取字眼,got=%q", got)
}
}
// TestBuildStatusLabelExtractorFailextractor 預檢失敗 → 換成「⚠ 萃取引擎未就緒:」。
func TestBuildStatusLabelExtractorFail(t *testing.T) {
s := supervisor.Status{State: supervisor.StateWatching}
sync := traySyncStatus{ExtractorOK: false, ExtractorError: "找不到 Claude 指令"}
got := buildStatusLabel(s, sync, "claude")
if !strings.HasPrefix(got, "⚠ 萃取引擎未就緒:") {
t.Errorf("extractor 失敗時應顯示警告,got=%q", got)
}
if !strings.Contains(got, "找不到 Claude 指令") {
t.Errorf("應包含錯誤原因,got=%q", got)
}
}
// TestBuildStatusLabelExtractorOKWithCountextractor 就緒且有萃取成功數 → 追加「已萃 N 檔」。
func TestBuildStatusLabelExtractorOKWithCount(t *testing.T) {
s := supervisor.Status{State: supervisor.StateWatching}
sync := traySyncStatus{ExtractorOK: true, ExtractedOK: 5}
got := buildStatusLabel(s, sync, "claude")
if !strings.Contains(got, "已萃 5 檔") {
t.Errorf("should show 已萃 5 檔,got=%q", got)
}
}
// TestBuildStatusLabelStopped:已暫停狀態不受 sync status 影響。
func TestBuildStatusLabelStopped(t *testing.T) {
s := supervisor.Status{State: supervisor.StateStopped}
sync := traySyncStatus{ExtractorOK: false, ExtractorError: "任何錯誤"}
got := buildStatusLabel(s, sync, "claude")
if got != "已暫停" {
t.Errorf("已暫停狀態應回「已暫停」,got=%q", got)
}
}
// TestDirectConfigPreservesClaudeBinconfig JSON round-trip 保留 claude_bin 欄位(t92 回寫驗收)。
func TestDirectConfigPreservesClaudeBin(t *testing.T) {
c := &directConfig{
CypherURL: "https://example.workers.dev",
Namespace: "demo",
ClaudeBin: "/opt/homebrew/bin/claude",
}
data, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal 失敗:%v", err)
}
if !strings.Contains(string(data), `"claude_bin":"/opt/homebrew/bin/claude"`) {
t.Errorf("序列化結果缺 claude_bin 欄:%s", data)
}
var back directConfig
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal 失敗:%v", err)
}
if back.ClaudeBin != c.ClaudeBin {
t.Errorf("round-trip 不一致:got %q, want %q", back.ClaudeBin, c.ClaudeBin)
}
}