From b9d880413d4fa43fd7d2a99f2920e16c4e86edcc Mon Sep 17 00:00:00 2001 From: richblack Date: Fri, 24 Jul 2026 15:06:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(daemon-beta=20t14/t15/t17):=20supervisor?= =?UTF-8?q?=20log=20=E6=AA=94=EF=BC=8Bremoved=20=E6=B8=85=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8D=A1=EF=BC=8B=E6=89=98=E7=9B=A4=E5=BB=BA=E6=96=B0?= =?UTF-8?q?=E8=B3=87=E6=96=99=E5=A4=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t14 supervisor:子行程 stdout(tee)/stderr 全落 ~/.arcrun-rag/collector.log, >5MB 改名 .old 重開(兩代輪替);落 log 失敗一律吞掉不擋看守。新增 2 測試 (輸出進檔+輪替觸發)。 - t15 direct removed(extractor 模式):雲端 takedown 2xx 後同步刪本地 system-dev/wiki/cards/<頁名>.md(存在才刪;刪失敗只記 warning 不擋)。 新增 2 測試(刪原檔→本地卡被清/卡已不在照常下架)。 - t17 arcrun-tray:tray 選單+設定視窗加「建立新資料夾並看守…」—— widget.Entry 輸入名稱→ ~/ 底下 mkdir(已存在沿用;擋路徑分隔與 ..)→ addWatchFolder+saveConfig+重啟看守+rebuildTray。 collector: go vet ✓、go test -count=1 ./... 43/43 綠;arcrun-tray: go build ✓(產物已刪) Co-Authored-By: Claude Fable 5 --- cmd/arcrun-tray/main.go | 49 +++++++++++++++ direct.go | 14 +++++ direct_extract_test.go | 110 ++++++++++++++++++++++++++++++++++ supervisor/supervisor.go | 86 +++++++++++++++++++++++--- supervisor/supervisor_test.go | 66 ++++++++++++++++++++ 5 files changed, 317 insertions(+), 8 deletions(-) diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 318b219..d02f63f 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -17,10 +17,12 @@ package main import ( "encoding/json" + "errors" "fmt" neturl "net/url" "os" "path/filepath" + "strings" "arcrun-rag/collector/supervisor" @@ -206,6 +208,51 @@ func main() { }, win) } + // t17:fyne 內建 folder picker 無法輸入名稱建新資料夾(leo 07-24 親測)—— + // 補一條「建立新資料夾並看守…」:文字輸入+確定 → 在 ~/ 底下 mkdir(已存在則沿用)→ 看守。 + newFolderAction := func() { + win.Show() + win.RequestFocus() + entry := widget.NewEntry() + entry.SetPlaceHolder("例如:我的知識庫") + content := container.NewVBox( + widget.NewLabel("輸入新資料夾名稱(會建立在你的家目錄底下):"), + entry, + ) + dialog.ShowCustomConfirm("建立新資料夾並看守", "確定", "取消", content, func(ok bool) { + if !ok { + return + } + name := strings.TrimSpace(entry.Text) + if name == "" { + return + } + // 只收「單層名稱」:擋路徑分隔與 ..(避免建到家目錄之外) + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + dialog.ShowError(errors.New("名稱不能包含 / 或 \\,也不能是 . 或 .."), win) + return + } + home, err := os.UserHomeDir() + if err != nil { + dialog.ShowError(err, win) + return + } + p := filepath.Join(home, name) + if err := os.MkdirAll(p, 0o755); err != nil { // 已存在=直接沿用 + dialog.ShowError(err, win) + return + } + addWatchFolder(cfg, p) + if err := saveConfig(cfg); err != nil { + dialog.ShowError(err, win) + return + } + restartWatch() + rebuildTray() + win.Hide() + }, win) + } + var pauseItem *fyne.MenuItem pauseItem = fyne.NewMenuItem("暫停看守", func() { st := sup.Status() @@ -246,6 +293,7 @@ func main() { } items = append(items, fyne.NewMenuItem("+ 新增知識資料夾…", addAction), + fyne.NewMenuItem("+ 建立新資料夾並看守…", newFolderAction), // t17 fyne.NewMenuItemSeparator(), pauseItem, ) @@ -274,6 +322,7 @@ func main() { widget.NewLabelWithStyle("Arcrun RAG 知識同步", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), widget.NewLabel("把檔案丟進你選的資料夾,就會自動進你的知識庫。\n這個程式會待在選單列/系統匣,關掉視窗它仍在背景看守。"), widget.NewButton("新增知識資料夾…", func() { addAction() }), + widget.NewButton("建立新資料夾並看守…", func() { newFolderAction() }), // t17 )) // 開機即看守(若設定完整) diff --git a/direct.go b/direct.go index e46003f..0482379 100644 --- a/direct.go +++ b/direct.go @@ -353,6 +353,20 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes exit = 1 } else { res.Status = "removed" + // t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡 + //(system-dev/wiki/cards/<頁名>.md),保持本地 wiki 與雲端一致。 + // 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。 + if cfg.Extractor != "" { + cardAbs := filepath.Join(absRoot, "system-dev", "wiki", "cards", pageNameOf(ev.Path)+".md") + if _, serr := os.Stat(cardAbs); serr == nil { + if rerr := os.Remove(cardAbs); rerr != nil { + results = append(results, DirectResult{ + Type: "warning", Path: cardAbs, Status: "skipped", + Error: "本地卡刪除失敗(不擋下架):" + rerr.Error(), + }) + } + } + } } results = append(results, res) } diff --git a/direct_extract_test.go b/direct_extract_test.go index 023714e..a502ffc 100644 --- a/direct_extract_test.go +++ b/direct_extract_test.go @@ -82,6 +82,116 @@ func TestDirectExtractorModeE2E(t *testing.T) { } } +// t15:extractor 模式刪原檔 → 雲端 takedown 成功後,本地萃出的卡也要被清掉。 +func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 原稿"), 0o644); err != nil { + t.Fatal(err) + } + + // 假 cypher:收 rag_ingest_card 與 rag_takedown_direct + var takedowns []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_takedown_direct/trigger") { + body, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(body, &m) + takedowns = append(takedowns, m) + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + + // stub claude:萃卡落地 + stubDir := t.TempDir() + stub := filepath.Join(stubDir, "claude") + script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# 報銷規則\\n## 一句話定義\\n測試卡\\n' > 'system-dev/wiki/cards/報銷規則.md'\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + cfg := &DirectConfig{ + WatchFolders: []string{root}, + Manifest: filepath.Join(t.TempDir(), "m.json"), + CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", + Library: "kb", Extractor: "claude", ClaudeBin: stub, + CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct", + // 單檔刪除=removed ratio 100%,預設 0.4 防呆會壓下事件;本測試聚焦下架路,放寬到 1.0 + //(1 > 1.0×1 為 false → 事件放行)。 + MaxRemoved: 1.0, + } + + // 第一輪:萃卡+上雲,本地卡存在 + if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { + t.Fatalf("第一輪 ingest 失敗 exit=%d", exit) + } + cardPath := filepath.Join(root, "system-dev", "wiki", "cards", "報銷規則.md") + if _, err := os.Stat(cardPath); err != nil { + t.Fatalf("前置失敗:卡片未落地 %v", err) + } + + // 刪原檔 → 第二輪:takedown 打出去、本地卡也被清 + if err := os.Remove(filepath.Join(root, "報銷規則.md")); err != nil { + t.Fatal(err) + } + results, exit, _ := RunDirectOnce(cfg, false) + if exit != 0 { + t.Fatalf("第二輪 exit=%d results=%+v", exit, results) + } + if len(results) != 1 || results[0].Status != "removed" { + t.Fatalf("results=%+v", results) + } + if len(takedowns) != 1 { + t.Fatalf("應恰好一次 takedown,got %d", len(takedowns)) + } + if pn, _ := takedowns[0]["page_name"].(string); pn != "報銷規則" { + t.Fatalf("takedown page_name=%q", pn) + } + if _, err := os.Stat(cardPath); !os.IsNotExist(err) { + t.Fatalf("本地卡應已被清(err=%v)", err) + } +} + +// t15:本地卡不存在時(存在才刪)下架照常成功,不多出 warning。 +func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + stubDir := t.TempDir() + stub := filepath.Join(stubDir, "claude") + script := "#!/bin/sh\nmkdir -p system-dev/wiki/cards\nprintf '# a\\n## 一句話定義\\n卡\\n' > system-dev/wiki/cards/a.md\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cfg := &DirectConfig{ + WatchFolders: []string{root}, + Manifest: filepath.Join(t.TempDir(), "m.json"), + CypherURL: srv.URL, Namespace: "demo", APIKey: "demo", + Extractor: "claude", ClaudeBin: stub, + CardIngestWF: "rag_ingest_card", RemovedWF: "rag_takedown_direct", + MaxRemoved: 1.0, + } + if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 { + t.Fatal("第一輪失敗") + } + // 模擬用戶已手動清走本地卡 → removed 分支「存在才刪」不應報錯或多出 warning + if err := os.Remove(filepath.Join(root, "system-dev", "wiki", "cards", "a.md")); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "a.md")); err != nil { + t.Fatal(err) + } + results, exit, _ := RunDirectOnce(cfg, false) + if exit != 0 || len(results) != 1 || results[0].Status != "removed" { + t.Fatalf("exit=%d results=%+v", exit, results) + } +} + // 萃取失敗=該檔標 failed、exit=1、manifest 不標(下輪重試),其他檔不受影響。 func TestDirectExtractorFailKeepsRetry(t *testing.T) { root := t.TempDir() diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index 9f8c612..57ccea4 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -15,7 +15,9 @@ import ( "context" "encoding/json" "io" + "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -48,11 +50,55 @@ type round struct { Results []json.RawMessage `json:"results"` } +// DefaultLogMaxBytes 是 collector.log 的輪替上限(超過即改名 .old 重開)。 +const DefaultLogMaxBytes = 5 << 20 // 5MB + +// defaultLogPath 回傳預設 log 檔位置:~/.arcrun-rag/collector.log(與 config/manifest 同窩)。 +func defaultLogPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" // 找不到家目錄=不落 log(best-effort,不擋看守) + } + return filepath.Join(home, ".arcrun-rag", "collector.log") +} + +// rotatingLog 是 append-only 的 log 檔 writer,帶簡單輪替: +// 寫入前若檔案將超過 max,就把現檔改名 .old(覆蓋舊 .old)後重開新檔。 +// 所有錯誤一律吞掉(log 是診斷輔助,絕不因落 log 失敗擋掉看守本體)。 +type rotatingLog struct { + mu sync.Mutex + path string + max int64 +} + +func (l *rotatingLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.path == "" { + return len(p), nil + } + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return len(p), nil + } + if fi, err := os.Stat(l.path); err == nil && fi.Size()+int64(len(p)) > l.max { + _ = os.Rename(l.path, l.path+".old") // 覆蓋既有 .old =最多留兩代 + } + f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return len(p), nil + } + defer f.Close() + _, _ = f.Write(p) + return len(p), nil +} + // Supervisor 看守單一 collector direct 行程。 type Supervisor struct { - BinPath string // collector 執行檔路徑(托盤 app bundle 內) - ConfigPath string // direct config.json 路徑 - Backoff time.Duration // 非預期退出後的重起間隔(0=預設 3s) + BinPath string // collector 執行檔路徑(托盤 app bundle 內) + ConfigPath string // direct config.json 路徑 + Backoff time.Duration // 非預期退出後的重起間隔(0=預設 3s) + LogPath string // 子行程輸出落地檔(空=~/.arcrun-rag/collector.log;t14) + LogMaxBytes int64 // log 輪替上限(0=DefaultLogMaxBytes) mu sync.Mutex status Status @@ -61,6 +107,7 @@ type Supervisor struct { running bool onChange func(Status) nowFn func() time.Time // 可注入時鐘(測試用;nil=time.Now) + logw *rotatingLog // 子行程輸出落地(lazy init,見 logWriter) } // New 建一個看守器。 @@ -75,6 +122,24 @@ func (s *Supervisor) SetOnChange(fn func(Status)) { s.mu.Unlock() } +// logWriter 回傳(lazy 建立)子行程輸出的 log writer(t14)。 +func (s *Supervisor) logWriter() *rotatingLog { + s.mu.Lock() + defer s.mu.Unlock() + if s.logw == nil { + p := s.LogPath + if p == "" { + p = defaultLogPath() + } + max := s.LogMaxBytes + if max <= 0 { + max = DefaultLogMaxBytes + } + s.logw = &rotatingLog{path: p, max: max} + } + return s.logw +} + func (s *Supervisor) now() time.Time { if s.nowFn != nil { return s.nowFn() @@ -198,7 +263,10 @@ func (s *Supervisor) runOnce(ctx context.Context) error { return err } - // stderr:留末行當錯誤脈絡 + // t14:子行程輸出全部落地 ~/.arcrun-rag/collector.log(帶輪替),托盤跑掛不用猜。 + lw := s.logWriter() + + // stderr:落 log + 留末行當錯誤脈絡 go func() { sc := bufio.NewScanner(stderr) for sc.Scan() { @@ -206,22 +274,24 @@ func (s *Supervisor) runOnce(ctx context.Context) error { if line == "" { continue } + _, _ = lw.Write([]byte("[stderr] " + line + "\n")) s.mu.Lock() s.status.LastError = line s.mu.Unlock() } }() - // stdout:用 json.Decoder 逐個 JSON 值解(容忍 MarshalIndent 的多行) - dec := json.NewDecoder(stdout) + // stdout:tee 進 log 檔,同時用 json.Decoder 逐個 JSON 值解(容忍 MarshalIndent 的多行) + tee := io.TeeReader(stdout, lw) + dec := json.NewDecoder(tee) for { var r round if derr := dec.Decode(&r); derr != nil { if derr == io.EOF { break } - // 非 JSON 雜訊:吞掉剩餘、跳出(行程仍由 Wait 收) - io.Copy(io.Discard, stdout) + // 非 JSON 雜訊:吞掉剩餘(仍經 tee 落 log)、跳出(行程仍由 Wait 收) + io.Copy(io.Discard, tee) break } at := parseAt(r.At) diff --git a/supervisor/supervisor_test.go b/supervisor/supervisor_test.go index 34911f3..f4a7507 100644 --- a/supervisor/supervisor_test.go +++ b/supervisor/supervisor_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync/atomic" "testing" "time" @@ -110,6 +111,71 @@ func TestSupervisorStopThenStartNotLeftStopped(t *testing.T) { } } +// t14:子行程 stdout(JSON 輪)與 stderr 都要落進 log 檔。 +func TestSupervisorWritesChildOutputToLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake collector script 走 POSIX shell") + } + dir := t.TempDir() + bin := filepath.Join(dir, "fake-collector.sh") + body := "#!/bin/sh\n" + + "printf '{\"at\":\"2026-07-24T00:00:00Z\",\"folder\":\"/tmp/kb\",\"results\":[]}\\n'\n" + + "echo 'boom-stderr-line' 1>&2\n" + + "sleep 2\n" + if err := os.WriteFile(bin, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(t.TempDir(), "collector.log") + s := New(bin, "cfg.json") + s.LogPath = logPath + s.Backoff = 20 * time.Millisecond + s.Start() + defer s.Stop() + + waitFor(t, 2*time.Second, func() bool { + data, _ := os.ReadFile(logPath) + return strings.Contains(string(data), "2026-07-24T00:00:00Z") && + strings.Contains(string(data), "[stderr] boom-stderr-line") + }, "stdout JSON 與 stderr 行都寫進 log 檔") +} + +// t14:log 檔超過上限 → 改名 .old 重開(簡單兩代輪替)。 +func TestSupervisorLogRotation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake collector script 走 POSIX shell") + } + dir := t.TempDir() + bin := filepath.Join(dir, "fake-collector.sh") + // 一輪印 ~120 bytes × 20 輪 > 上限 400 bytes → 必觸發輪替 + body := "#!/bin/sh\ni=0\nwhile [ $i -lt 20 ]; do\n" + + "printf '{\"at\":\"2026-07-24T00:00:00Z\",\"folder\":\"/tmp/kb-padding-padding-padding-padding-padding\",\"results\":[]}\\n'\n" + + "i=$((i+1))\ndone\nsleep 2\n" + if err := os.WriteFile(bin, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(t.TempDir(), "collector.log") + s := New(bin, "cfg.json") + s.LogPath = logPath + s.LogMaxBytes = 400 + s.Backoff = 20 * time.Millisecond + s.Start() + defer s.Stop() + + waitFor(t, 2*time.Second, func() bool { + _, err := os.Stat(logPath + ".old") + return err == nil + }, "輪替後 .old 檔存在") + // 現役檔已重開=大小不會無限長(上限 + 單次寫入緩衝的餘裕) + waitFor(t, 2*time.Second, func() bool { return s.Status().Rounds >= 20 }, "20 rounds parsed") + fi, err := os.Stat(logPath) + if err != nil { + t.Fatalf("現役 log 檔應存在:%v", err) + } + if fi.Size() > 2*400+4096 { + t.Fatalf("現役 log 檔未受控:size=%d", fi.Size()) + } +} + func TestSupervisorOnChangeFires(t *testing.T) { bin := fakeCollector(t, 2, true) s := New(bin, "cfg.json")