feat(daemon-beta t14/t15/t17): supervisor log 檔+removed 清本地卡+托盤建新資料夾

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 15:06:21 +08:00
parent 6e5009ff35
commit b9d880413d
5 changed files with 317 additions and 8 deletions
+49
View File
@@ -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)
}
// t17fyne 內建 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
))
// 開機即看守(若設定完整)
+14
View File
@@ -353,6 +353,20 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
exit = 1
} else {
res.Status = "removed"
// t15extractor 模式雲端下架成功後,同步清掉本地萃出的卡
//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)
}
+110
View File
@@ -82,6 +82,116 @@ func TestDirectExtractorModeE2E(t *testing.T) {
}
}
// t15extractor 模式刪原檔 → 雲端 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("應恰好一次 takedowngot %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()
+78 -8
View File
@@ -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,就把現檔改名 <path>.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.logt14
LogMaxBytes int64 // log 輪替上限(0DefaultLogMaxBytes
mu sync.Mutex
status Status
@@ -61,6 +107,7 @@ type Supervisor struct {
running bool
onChange func(Status)
nowFn func() time.Time // 可注入時鐘(測試用;niltime.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)
// stdouttee 進 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)
+66
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
@@ -110,6 +111,71 @@ func TestSupervisorStopThenStartNotLeftStopped(t *testing.T) {
}
}
// t14:子行程 stdoutJSON 輪)與 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 檔")
}
// t14log 檔超過上限 → 改名 .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")