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:
+87
-9
@@ -71,7 +71,8 @@ type directConfig struct {
|
|||||||
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
|
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形)
|
||||||
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
|
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email)
|
||||||
Library string `json:"library,omitempty"`
|
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"` // t92:collector fallback 找到後回寫,下次直達
|
||||||
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑)
|
Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑)
|
||||||
IngestWF string `json:"ingest_workflow,omitempty"`
|
IngestWF string `json:"ingest_workflow,omitempty"`
|
||||||
RemovedWF string `json:"removed_workflow,omitempty"`
|
RemovedWF string `json:"removed_workflow,omitempty"`
|
||||||
@@ -79,6 +80,52 @@ type directConfig struct {
|
|||||||
MaxRemoved float64 `json:"max_removed_ratio,omitempty"`
|
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 依萃取狀態決定要在「看守中」後面追加什麼文案。
|
||||||
|
// hasExtractor=config 有設定 extractor(claude/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/
|
// appDir 是設定與 manifest 落地處:~/.arcrun-rag/
|
||||||
func appDir() string {
|
func appDir() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
@@ -610,6 +657,11 @@ func main() {
|
|||||||
if !hasTray {
|
if !hasTray {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// t91:每次重建選單時讀最新狀態,確保看守中的萃取結果即時反映。
|
||||||
|
sync := loadAppSyncStatus()
|
||||||
|
supStatus := sup.Status()
|
||||||
|
statusItem.Label = "狀態:" + buildStatusLabel(supStatus, sync, cfg.Extractor)
|
||||||
|
|
||||||
// t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行);
|
// t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行);
|
||||||
// 有 cypher_url 時再加一行縮短的 host(fyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。
|
// 有 cypher_url 時再加一行縮短的 host(fyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。
|
||||||
connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil)
|
connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil)
|
||||||
@@ -622,7 +674,21 @@ func main() {
|
|||||||
hostItem.Disabled = true
|
hostItem.Disabled = true
|
||||||
items = append(items, hostItem)
|
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() {
|
for _, f := range cfg.Folders() {
|
||||||
folder := f // capture
|
folder := f // capture
|
||||||
it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() {
|
it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() {
|
||||||
@@ -673,11 +739,10 @@ func main() {
|
|||||||
a.Lifecycle().SetOnStarted(func() { hideDockIcon() })
|
a.Lifecycle().SetOnStarted(func() { hideDockIcon() })
|
||||||
refreshTray := rebuildTray
|
refreshTray := rebuildTray
|
||||||
|
|
||||||
// 狀態變更 → 刷新 tray 文案(回呼在 supervisor goroutine,切回 UI thread)
|
// 狀態變更 → 刷新 tray 選單(回呼在 supervisor goroutine,切回 UI thread)。
|
||||||
|
// t91:label 設定統一在 rebuildTray 裡做(同時讀取 status.json);
|
||||||
|
// 此版 fyne 無 fyne.Do,tray label 更新直接做即可(2026-07-21 真機 build 實測修正)。
|
||||||
sup.SetOnChange(func(s supervisor.Status) {
|
sup.SetOnChange(func(s supervisor.Status) {
|
||||||
// 此版 fyne 無 fyne.Do;tray menu label 更新直接做即可
|
|
||||||
// (2026-07-21 真機 build 實測修正)
|
|
||||||
statusItem.Label = "狀態:" + humanStatus(s)
|
|
||||||
refreshTray()
|
refreshTray()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -703,13 +768,26 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// humanStatus 把狀態機轉成白話(給不懂內部的人)。
|
// humanStatus 把狀態機轉成白話(給不懂內部的人)。
|
||||||
|
// 需要考慮萃取狀態時,改用 buildStatusLabel。
|
||||||
func humanStatus(s supervisor.Status) string {
|
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 {
|
switch s.State {
|
||||||
case supervisor.StateWatching:
|
case supervisor.StateWatching:
|
||||||
if !s.LastRoundAt.IsZero() {
|
// t92:extractor 預檢失敗時,直接換掉「看守中」文案
|
||||||
return fmt.Sprintf("看守中 · 上次同步 %s", s.LastRoundAt.Local().Format("15:04"))
|
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:
|
case supervisor.StateStarting:
|
||||||
return "啟動中…"
|
return "啟動中…"
|
||||||
case supervisor.StateError:
|
case supervisor.StateError:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"arcrun-rag/collector/supervisor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// t26:暱稱 > email > 未設定(leo 07-24 拍板:CF 全程隱形,這條邏輯不該提到 cypher/CF)。
|
// t26:暱稱 > email > 未設定(leo 07-24 拍板:CF 全程隱形,這條邏輯不該提到 cypher/CF)。
|
||||||
@@ -183,3 +185,73 @@ func TestApplyRemoteConfigKeepsFoldersOnSameInstance(t *testing.T) {
|
|||||||
t.Error("同實例不應清空資料夾清單")
|
t.Error("同實例不應清空資料夾清單")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── t91/t92:buildStatusLabel 與 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildStatusLabelExtractorFail:extractor 預檢失敗 → 換成「⚠ 萃取引擎未就緒:」。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildStatusLabelExtractorOKWithCount:extractor 就緒且有萃取成功數 → 追加「已萃 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectConfigPreservesClaudeBin:config 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,10 +270,32 @@ type DirectResult struct {
|
|||||||
|
|
||||||
// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。
|
// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。
|
||||||
// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
||||||
|
// 額外:
|
||||||
|
// - 預檢 extractor 可用性(t92-②),有 fallback 時更新 cfg.ClaudeBin(in-memory,呼叫端存檔)。
|
||||||
|
// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性)。
|
||||||
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
||||||
results := []DirectResult{}
|
results := []DirectResult{}
|
||||||
exit := 0
|
exit := 0
|
||||||
var lastPayload *TriggerPayload
|
var lastPayload *TriggerPayload
|
||||||
|
|
||||||
|
// t92-②:預檢 extractor,有 fallback 路徑時就地更新 cfg.ClaudeBin(供下游直接使用)。
|
||||||
|
extractorOK := true
|
||||||
|
extractorError := ""
|
||||||
|
if cfg.Extractor == "claude" {
|
||||||
|
resolved, ferr := FindClaudeBin(cfg.ClaudeBin)
|
||||||
|
if ferr != nil {
|
||||||
|
extractorOK = false
|
||||||
|
extractorError = "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路"
|
||||||
|
} else if resolved != cfg.ClaudeBin {
|
||||||
|
cfg.ClaudeBin = resolved // in-memory 回寫;runDirect 偵到變化才存磁碟
|
||||||
|
}
|
||||||
|
} else if cfg.Extractor == "gemma" {
|
||||||
|
if strings.TrimSpace(cfg.GeminiAPIKey) == "" {
|
||||||
|
extractorOK = false
|
||||||
|
extractorError = "金鑰是空的——請在設定裡輸入 Gemini API Key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
multi := len(cfg.Folders()) > 1
|
multi := len(cfg.Folders()) > 1
|
||||||
for _, root := range cfg.Folders() {
|
for _, root := range cfg.Folders() {
|
||||||
r, e, p := runDirectOnceRoot(cfg, root, dryRun)
|
r, e, p := runDirectOnceRoot(cfg, root, dryRun)
|
||||||
@@ -290,9 +312,75 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
|||||||
lastPayload = p
|
lastPayload = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t91:每輪寫狀態檔(只有 extractor 模式才有意義的計數;direct 雲端萃模式 extracted_ok=0)。
|
||||||
|
if !dryRun && cfg.Manifest != "" {
|
||||||
|
st := SyncStatus{
|
||||||
|
LastSync: time.Now().Format(time.RFC3339),
|
||||||
|
ExtractorOK: extractorOK,
|
||||||
|
ExtractorError: extractorError,
|
||||||
|
}
|
||||||
|
if cfg.Extractor != "" {
|
||||||
|
for _, r := range results {
|
||||||
|
switch r.Status {
|
||||||
|
case "ingested":
|
||||||
|
st.ExtractedOK++
|
||||||
|
case "failed":
|
||||||
|
st.ExtractFailed++
|
||||||
|
st.Failures = append(st.Failures, ExtractFail{
|
||||||
|
Path: r.Path,
|
||||||
|
Error: shortError(r.Error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results, exit, lastPayload
|
return results, exit, lastPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortError 把錯誤字串截為一句話(供 UI 顯示,不要超過 120 字)。
|
||||||
|
func shortError(msg string) string {
|
||||||
|
if len([]rune(msg)) <= 120 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
runes := []rune(msg)
|
||||||
|
return string(runes[:120]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckExtractor 預檢萃取器是否可用(不執行萃取、不打 API)。
|
||||||
|
// 只檢「可執行檔存在且可執行」或「金鑰非空」。
|
||||||
|
// extractor 空(舊制直送)一律回 (true, "")。
|
||||||
|
func CheckExtractor(cfg *DirectConfig) (ok bool, errMsg string) {
|
||||||
|
switch cfg.Extractor {
|
||||||
|
case "claude":
|
||||||
|
if _, err := FindClaudeBin(cfg.ClaudeBin); err != nil {
|
||||||
|
return false, "找不到 Claude 指令——請確認 Claude Code 已安裝,或改用 Gemma 萃取路"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
case "gemma":
|
||||||
|
if strings.TrimSpace(cfg.GeminiAPIKey) == "" {
|
||||||
|
return false, "金鑰是空的——請在設定裡輸入 Gemini API Key"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
default:
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveDirectConfig 把 DirectConfig 回寫到 configPath(t92:找到 claude fallback 路徑後持久化)。
|
||||||
|
// 只寫 claude_bin 等萃取相關欄位不會影響用戶的其他設定(JSON 完整覆蓋整個 config)。
|
||||||
|
func saveDirectConfig(configPath string, cfg *DirectConfig) error {
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(configPath, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
// runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed,2xx 後回寫該根 manifest。
|
// runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed,2xx 後回寫該根 manifest。
|
||||||
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectResult, int, *TriggerPayload) {
|
||||||
results := []DirectResult{}
|
results := []DirectResult{}
|
||||||
@@ -482,6 +570,9 @@ func runDirect(args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t92:第一輪若 claude 找到了 fallback 路徑,把更新後的 claude_bin 存回 config 檔(下次直達)。
|
||||||
|
origClaudeBin := cfg.ClaudeBin
|
||||||
|
|
||||||
runOne := func() int {
|
runOne := func() int {
|
||||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||||
out, _ := json.MarshalIndent(struct {
|
out, _ := json.MarshalIndent(struct {
|
||||||
@@ -493,6 +584,17 @@ func runDirect(args []string) int {
|
|||||||
return exit
|
return exit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claude_bin 回寫:只在找到 fallback 路徑時才存(避免頻繁寫磁碟)
|
||||||
|
persistClaudeBinIfChanged := func() {
|
||||||
|
if cfg.ClaudeBin != origClaudeBin && cfg.ClaudeBin != "" && *configPath != "" {
|
||||||
|
if serr := saveDirectConfig(*configPath, cfg); serr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "claude_bin 回寫 config 失敗(不擋看守):%v\n", serr)
|
||||||
|
} else {
|
||||||
|
origClaudeBin = cfg.ClaudeBin // 只存一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。
|
// 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。
|
||||||
// dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。
|
// dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。
|
||||||
if !*once && !*dryRun {
|
if !*once && !*dryRun {
|
||||||
@@ -509,12 +611,15 @@ func runDirect(args []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *once {
|
if *once {
|
||||||
return runOne()
|
code := runOne()
|
||||||
|
persistClaudeBinIfChanged()
|
||||||
|
return code
|
||||||
}
|
}
|
||||||
// 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。
|
// 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。
|
||||||
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
|
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
|
||||||
strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
||||||
runOne()
|
runOne()
|
||||||
|
persistClaudeBinIfChanged() // 第一輪後立即回寫(下次重啟直達)
|
||||||
ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second)
|
ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
+48
-7
@@ -16,9 +16,51 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// claudeFallbackPaths 是 PATH 找不到 claude 時依序嘗試的絕對路徑清單。
|
||||||
|
// Finder 啟動的 GUI app 只拿最小 PATH(/usr/bin:/bin 等),brew 裝的 claude 在此找不到(t92)。
|
||||||
|
// 測試可覆蓋此變數注入暫存目錄,無需真的安裝 Claude Code。
|
||||||
|
var claudeFallbackPaths = defaultClaudeFallbackPaths()
|
||||||
|
|
||||||
|
func defaultClaudeFallbackPaths() []string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
paths := []string{
|
||||||
|
"/opt/homebrew/bin/claude",
|
||||||
|
"/usr/local/bin/claude",
|
||||||
|
}
|
||||||
|
if home != "" {
|
||||||
|
paths = append(paths,
|
||||||
|
filepath.Join(home, ".local", "bin", "claude"),
|
||||||
|
filepath.Join(home, ".claude", "local", "claude"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindClaudeBin 找可執行的 claude 二進位:先試 hint(空="claude")在 PATH;
|
||||||
|
// 找不到再依序掃 claudeFallbackPaths(解決 Finder 啟動 app PATH 不含 /opt/homebrew/bin 的問題)。
|
||||||
|
// 回傳完整絕對路徑,或「全都找不到」的白話錯誤。
|
||||||
|
func FindClaudeBin(hint string) (string, error) {
|
||||||
|
if hint == "" {
|
||||||
|
hint = "claude"
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath(hint); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
for _, p := range claudeFallbackPaths {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tried := append([]string{hint}, claudeFallbackPaths...)
|
||||||
|
return "", fmt.Errorf("找不到 Claude 指令(試了 %s)——請確認 Claude Code 已安裝,或改用 Gemma 萃取路",
|
||||||
|
strings.Join(tried, "、"))
|
||||||
|
}
|
||||||
|
|
||||||
// cardsRelDir 是 template 規約的卡片產物區(相對監看根)。
|
// cardsRelDir 是 template 規約的卡片產物區(相對監看根)。
|
||||||
const cardsRelDir = "system-dev/wiki/cards"
|
const cardsRelDir = "system-dev/wiki/cards"
|
||||||
|
|
||||||
@@ -55,13 +97,12 @@ func diffCards(before, after map[string]fileState) []string {
|
|||||||
const claudeExtractTimeout = 10 * time.Minute
|
const claudeExtractTimeout = 10 * time.Minute
|
||||||
|
|
||||||
// ExtractWithClaude 叫起用戶自己的 claude 跑 template 的 /rag-extract-file(task 3)。
|
// ExtractWithClaude 叫起用戶自己的 claude 跑 template 的 /rag-extract-file(task 3)。
|
||||||
// binPath 空="claude"(PATH 尋找)。回傳本次產出的卡片相對路徑。
|
// binPath 空或 PATH 找不到時,自動掃 claudeFallbackPaths(t92 Finder 啟動 PATH 不完整)。
|
||||||
|
// 回傳本次產出的卡片相對路徑。
|
||||||
func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
|
func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
|
||||||
if binPath == "" {
|
resolved, err := FindClaudeBin(binPath)
|
||||||
binPath = "claude"
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
if _, err := exec.LookPath(binPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("找不到 claude 執行檔(%s):%w——請確認已安裝 Claude Code,或改用 gemma 萃取路", binPath, err)
|
|
||||||
}
|
}
|
||||||
before := snapshotCards(absRoot)
|
before := snapshotCards(absRoot)
|
||||||
|
|
||||||
@@ -70,7 +111,7 @@ func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) {
|
|||||||
// /rag-extract-file=template 既有萃取 skill;cwd=監看根(template 已代裝,skill 就在 .claude/commands/)。
|
// /rag-extract-file=template 既有萃取 skill;cwd=監看根(template 已代裝,skill 就在 .claude/commands/)。
|
||||||
// --permission-mode acceptEdits:headless 無人按核准,寫卡會卡在權限確認(07-24 真機 e2e 實撞);
|
// --permission-mode acceptEdits:headless 無人按核准,寫卡會卡在權限確認(07-24 真機 e2e 實撞);
|
||||||
// acceptEdits 只自動放行檔案編輯、不放行任意 Bash,範圍侷限在監看根(cwd)。
|
// acceptEdits 只自動放行檔案編輯、不放行任意 Bash,範圍侷限在監看根(cwd)。
|
||||||
cmd := exec.CommandContext(ctx, binPath, "-p", fmt.Sprintf("/rag-extract-file %s", relPath), "--permission-mode", "acceptEdits")
|
cmd := exec.CommandContext(ctx, resolved, "-p", fmt.Sprintf("/rag-extract-file %s", relPath), "--permission-mode", "acceptEdits")
|
||||||
cmd.Dir = absRoot
|
cmd.Dir = absRoot
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ func TestExtractWithClaudeNoCard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 找不到執行檔=引導改用 gemma 路的錯誤訊息。
|
// 找不到執行檔=引導改用 gemma 路的錯誤訊息。
|
||||||
|
// t92:覆蓋 fallback 清單為空,確保即使本機有 claude 安裝,測試仍能驗到「找不到」路徑。
|
||||||
func TestExtractWithClaudeMissingBin(t *testing.T) {
|
func TestExtractWithClaudeMissingBin(t *testing.T) {
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
claudeFallbackPaths = nil // 停用 fallback,讓找不到 /no/such/claude-bin 就直接報錯
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
if _, err := ExtractWithClaude("/no/such/claude-bin", t.TempDir(), "x.md"); err == nil {
|
if _, err := ExtractWithClaude("/no/such/claude-bin", t.TempDir(), "x.md"); err == nil {
|
||||||
t.Fatal("缺執行檔應報錯")
|
t.Fatal("缺執行檔應報錯")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// sync_status.go — 每輪同步後的彙總狀態(t91 狀態可見性)。
|
||||||
|
// 寫成 JSON 供托盤讀取,讓使用者第一眼看到萃取是否正常。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
|
||||||
|
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
|
||||||
|
type SyncStatus struct {
|
||||||
|
LastSync string `json:"last_sync,omitempty"` // RFC3339,最近一輪完成時間
|
||||||
|
ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨資料夾累計)
|
||||||
|
ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數
|
||||||
|
Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因)
|
||||||
|
ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢)
|
||||||
|
ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractFail 記一筆萃取失敗(路徑+白話原因)。
|
||||||
|
type ExtractFail struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusFilePath 回傳狀態檔路徑:與 manifest 同目錄的 status.json。
|
||||||
|
func StatusFilePath(manifestPath string) string {
|
||||||
|
return filepath.Join(filepath.Dir(manifestPath), "status.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSyncStatus 寫入(覆蓋)狀態檔。失敗只印 stderr,不擋看守本體。
|
||||||
|
func SaveSyncStatus(path string, s SyncStatus) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(s, "", " ")
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSyncStatus 讀取狀態檔;不存在或解析失敗回零值+error(托盤自行降級)。
|
||||||
|
func LoadSyncStatus(path string) (SyncStatus, error) {
|
||||||
|
var s SyncStatus
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &s)
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// sync_status_test.go — t91/t92 狀態檔與 fallback 路徑邏輯單元測試。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── FindClaudeBin fallback(t92)──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestFindClaudeBinPathHit:hint 在 PATH 能找到時,直接回傳,不進 fallback。
|
||||||
|
func TestFindClaudeBinPathHit(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("此測試僅在 Unix 執行")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := filepath.Join(dir, "claude")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
claudeFallbackPaths = nil
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
|
found, err := FindClaudeBin("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PATH 有 claude 卻找不到:%v", err)
|
||||||
|
}
|
||||||
|
if found != bin {
|
||||||
|
t.Errorf("found=%q want=%q", found, bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindClaudeBinFallback:PATH 找不到時,依序掃 claudeFallbackPaths 並命中。
|
||||||
|
// 模擬 Finder 啟動的 GUI app PATH 不含 /opt/homebrew/bin 的情境(t92 根因)。
|
||||||
|
func TestFindClaudeBinFallback(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("此測試僅在 Unix 執行")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := filepath.Join(dir, "claude")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
// 清單:第一個不存在(跳過)、第二個是 bin(命中)
|
||||||
|
claudeFallbackPaths = []string{filepath.Join(dir, "no-such"), bin}
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
|
found, err := FindClaudeBin("/definitely/not/there")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fallback 應找到 %s,got err=%v", bin, err)
|
||||||
|
}
|
||||||
|
if found != bin {
|
||||||
|
t.Errorf("found=%q want=%q", found, bin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindClaudeBinAllMiss:PATH 和所有 fallback 都找不到 → 回誠實錯誤。
|
||||||
|
func TestFindClaudeBinAllMiss(t *testing.T) {
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
claudeFallbackPaths = []string{"/does/not/exist/claude"}
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
|
_, err := FindClaudeBin("/also/does/not/exist")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("全部找不到應回錯誤")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindClaudeBinNotExecutable:fallback 路徑存在但不可執行,不應採用。
|
||||||
|
func TestFindClaudeBinNotExecutable(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("此測試僅在 Unix 執行")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
notExec := filepath.Join(dir, "claude")
|
||||||
|
if err := os.WriteFile(notExec, []byte("not exec"), 0o644); err != nil { // 0644 無執行位
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
claudeFallbackPaths = []string{notExec}
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
|
_, err := FindClaudeBin("/no/such")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("不可執行的檔不應被當成可用 claude")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SyncStatus 狀態檔寫入/讀取(t91)────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestSyncStatusRoundTrip:寫進去的結構讀出來完全相同。
|
||||||
|
func TestSyncStatusRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "status.json")
|
||||||
|
|
||||||
|
want := SyncStatus{
|
||||||
|
LastSync: "2026-07-28T10:00:00Z",
|
||||||
|
ExtractedOK: 3,
|
||||||
|
ExtractFailed: 1,
|
||||||
|
Failures: []ExtractFail{
|
||||||
|
{Path: "重要文件.md", Error: "找不到 Claude 指令"},
|
||||||
|
},
|
||||||
|
ExtractorOK: false,
|
||||||
|
ExtractorError: "找不到 Claude 指令",
|
||||||
|
}
|
||||||
|
if err := SaveSyncStatus(path, want); err != nil {
|
||||||
|
t.Fatalf("SaveSyncStatus 失敗:%v", err)
|
||||||
|
}
|
||||||
|
got, err := LoadSyncStatus(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSyncStatus 失敗:%v", err)
|
||||||
|
}
|
||||||
|
if got.LastSync != want.LastSync {
|
||||||
|
t.Errorf("LastSync=%q want=%q", got.LastSync, want.LastSync)
|
||||||
|
}
|
||||||
|
if got.ExtractedOK != want.ExtractedOK || got.ExtractFailed != want.ExtractFailed {
|
||||||
|
t.Errorf("counts: ok=%d fail=%d, want ok=%d fail=%d",
|
||||||
|
got.ExtractedOK, got.ExtractFailed, want.ExtractedOK, want.ExtractFailed)
|
||||||
|
}
|
||||||
|
if len(got.Failures) != 1 || got.Failures[0].Path != "重要文件.md" {
|
||||||
|
t.Errorf("Failures=%+v", got.Failures)
|
||||||
|
}
|
||||||
|
if got.ExtractorOK || got.ExtractorError != want.ExtractorError {
|
||||||
|
t.Errorf("extractor: ok=%v err=%q", got.ExtractorOK, got.ExtractorError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadSyncStatusMissingFile:檔不存在回零值+error,不 panic。
|
||||||
|
func TestLoadSyncStatusMissingFile(t *testing.T) {
|
||||||
|
_, err := LoadSyncStatus(filepath.Join(t.TempDir(), "no-status.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("不存在的檔應回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatusFilePath:依 manifest 路徑回傳同目錄 status.json。
|
||||||
|
func TestStatusFilePath(t *testing.T) {
|
||||||
|
got := StatusFilePath("/home/leo/.arcrun-rag/manifest.json")
|
||||||
|
want := "/home/leo/.arcrun-rag/status.json"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("StatusFilePath=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RunDirectOnce 寫狀態檔(t91 整合)────────────────────────────────────────
|
||||||
|
|
||||||
|
// TestRunDirectOnceWritesStatus:RunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
|
||||||
|
// 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。
|
||||||
|
func TestRunDirectOnceWritesStatus(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("stub 腳本測試僅跑 Unix")
|
||||||
|
}
|
||||||
|
// 建可執行的假 claude stub
|
||||||
|
stubDir := t.TempDir()
|
||||||
|
stub := filepath.Join(stubDir, "claude")
|
||||||
|
if err := os.WriteFile(stub, []byte("#!/bin/sh\necho ok"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空資料夾 → 零事件 → 無 HTTP 呼叫
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestDir := t.TempDir()
|
||||||
|
cfg := &DirectConfig{
|
||||||
|
WatchFolders: []string{root},
|
||||||
|
Manifest: filepath.Join(manifestDir, "manifest.json"),
|
||||||
|
CypherURL: "https://unused.example", Namespace: "demo",
|
||||||
|
Extractor: "claude", ClaudeBin: stub,
|
||||||
|
MaxRemoved: DefaultMaxRemovedRatio,
|
||||||
|
}
|
||||||
|
RunDirectOnce(cfg, false)
|
||||||
|
|
||||||
|
statusPath := StatusFilePath(cfg.Manifest)
|
||||||
|
if _, err := os.Stat(statusPath); err != nil {
|
||||||
|
t.Fatalf("RunDirectOnce 後應存在 status.json:%v", err)
|
||||||
|
}
|
||||||
|
st, err := LoadSyncStatus(statusPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSyncStatus 失敗:%v", err)
|
||||||
|
}
|
||||||
|
if !st.ExtractorOK {
|
||||||
|
t.Errorf("可用 stub claude 時 ExtractorOK 應為 true,ExtractorError=%q", st.ExtractorError)
|
||||||
|
}
|
||||||
|
if st.LastSync == "" {
|
||||||
|
t.Error("LastSync 應非空")
|
||||||
|
}
|
||||||
|
// 空資料夾 → 零事件
|
||||||
|
if st.ExtractedOK != 0 || st.ExtractFailed != 0 {
|
||||||
|
t.Errorf("空資料夾應零計數,got ok=%d fail=%d", st.ExtractedOK, st.ExtractFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunDirectOnceWritesStatusExtractorFail:找不到 claude 時 status 應記 ExtractorOK=false。
|
||||||
|
func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) {
|
||||||
|
orig := claudeFallbackPaths
|
||||||
|
claudeFallbackPaths = []string{} // 空 fallback,確保找不到
|
||||||
|
defer func() { claudeFallbackPaths = orig }()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
manifestDir := t.TempDir()
|
||||||
|
cfg := &DirectConfig{
|
||||||
|
WatchFolders: []string{root},
|
||||||
|
Manifest: filepath.Join(manifestDir, "manifest.json"),
|
||||||
|
CypherURL: "https://unused.example", Namespace: "demo",
|
||||||
|
Extractor: "claude", ClaudeBin: "/no/such/claude",
|
||||||
|
MaxRemoved: DefaultMaxRemovedRatio,
|
||||||
|
}
|
||||||
|
RunDirectOnce(cfg, false)
|
||||||
|
|
||||||
|
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSyncStatus 失敗:%v", err)
|
||||||
|
}
|
||||||
|
if st.ExtractorOK {
|
||||||
|
t.Error("找不到 claude 時 ExtractorOK 應為 false")
|
||||||
|
}
|
||||||
|
if st.ExtractorError == "" {
|
||||||
|
t.Error("ExtractorError 應有白話說明")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user