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:
@@ -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.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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user