Files
arcrun-collector/supervisor/supervisor.go
T
Leo b9d880413d 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>
2026-07-24 15:06:21 +08:00

318 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package supervisor 看守 `collector direct` 常駐行程,供托盤殼(fyne)呼叫。
//
// 為什麼子行程而非 in-process(誠實界定,leo 2026-07-21 偏好 in-process):
//
// collector 現為 `package main`,真正 in-process 複用需把核心抽成 library 套件
// (重構動到已由 leo 實機驗過的引擎,有回歸風險)。本版先以「子行程+讀 stdout JSON 狀態」
// 達成看守(collector 零改、崩潰隔離、純 stdlib 可單元測),in-process 抽取列為可選後續。
// 對用戶行為完全相同(都是「背景看守資料夾」)。
//
// 純 stdlib、無 fyne 依賴 → 可在 CI/sandbox `go test` 驗證(托盤 GUI 需真機建置)。
package supervisor
import (
"bufio"
"context"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// State 是看守狀態機。
type State string
const (
StateStopped State = "stopped" // 未啟動或已停止
StateStarting State = "starting" // 行程剛拉起、尚無第一輪
StateWatching State = "watching" // 正常看守中(已有掃描輪)
StateError State = "error" // 行程非預期退出、等待重起
)
// Status 是托盤要顯示的即時狀態(值型別、複製安全)。
type Status struct {
State State
Since time.Time // 進入目前 State 的時間
LastRoundAt time.Time // 最近一輪掃描完成時間(collector stdout 的 at
Rounds int // 累計掃描輪數
Restarts int // 累計重起次數
LastError string // 最近一次錯誤(stderr 末行 / 退出原因)
}
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
type round struct {
At string `json:"at"`
Folder string `json:"folder"`
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)
LogPath string // 子行程輸出落地檔(空=~/.arcrun-rag/collector.logt14
LogMaxBytes int64 // log 輪替上限(0DefaultLogMaxBytes
mu sync.Mutex
status Status
cancel context.CancelFunc
done chan struct{} // loop 結束時關閉(Stop 等它,避免 Stop→Start 舊 loop 覆寫狀態)
running bool
onChange func(Status)
nowFn func() time.Time // 可注入時鐘(測試用;niltime.Now
logw *rotatingLog // 子行程輸出落地(lazy init,見 logWriter
}
// New 建一個看守器。
func New(binPath, configPath string) *Supervisor {
return &Supervisor{BinPath: binPath, ConfigPath: configPath}
}
// SetOnChange 註冊狀態變更回呼(托盤用來刷新選單/icon)。回呼在 supervisor 內部 goroutine 呼叫。
func (s *Supervisor) SetOnChange(fn func(Status)) {
s.mu.Lock()
s.onChange = fn
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()
}
return time.Now()
}
// setState 原子更新狀態並觸發回呼(回呼在鎖外呼叫,避免死鎖)。
func (s *Supervisor) setState(mut func(*Status)) {
s.mu.Lock()
prev := s.status.State
mut(&s.status)
if s.status.State != prev {
s.status.Since = s.now()
}
snap := s.status
cb := s.onChange
s.mu.Unlock()
if cb != nil {
cb(snap)
}
}
// Status 回傳目前狀態快照。
func (s *Supervisor) Status() Status {
s.mu.Lock()
defer s.mu.Unlock()
return s.status
}
// Start 啟動看守(非阻塞)。已在跑則忽略。崩潰自動重起,直到 Stop。
func (s *Supervisor) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
s.cancel = cancel
s.done = done
s.running = true
s.mu.Unlock()
go func() {
defer close(done)
s.loop(ctx)
}()
}
// Stop 停止看守,**等目前 loop 完全收掉才回**——這樣 Stop→Start(如換資料夾)不會被舊 loop
// 的 setState(stopped) 覆寫新狀態。冪等;未在跑時直接回。
func (s *Supervisor) Stop() {
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
cancel := s.cancel
done := s.done
s.running = false
s.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done // 等 loop goroutine 真的結束
}
s.setState(func(st *Status) { st.State = StateStopped })
}
func (s *Supervisor) backoff() time.Duration {
if s.Backoff > 0 {
return s.Backoff
}
return 3 * time.Second
}
// loop 是看守主迴圈:拉起行程→串流 stdout 更新狀態→退出即重起(除非取消)。
func (s *Supervisor) loop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
s.setState(func(st *Status) { st.State = StateStarting })
err := s.runOnce(ctx)
if ctx.Err() != nil { // 被 Stop 取消=正常收工
s.setState(func(st *Status) { st.State = StateStopped })
return
}
// 非預期退出:記錯、重起計數、退避後再拉
msg := "行程結束"
if err != nil {
msg = err.Error()
}
s.setState(func(st *Status) {
st.State = StateError
st.LastError = msg
st.Restarts++
})
select {
case <-ctx.Done():
s.setState(func(st *Status) { st.State = StateStopped })
return
case <-time.After(s.backoff()):
}
}
}
// runOnce 跑一次 collector direct 行程,串流其 stdout JSON 更新狀態,回傳退出原因。
func (s *Supervisor) runOnce(ctx context.Context) error {
cmd := exec.CommandContext(ctx, s.BinPath, "direct", "--config", s.ConfigPath)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
// t14:子行程輸出全部落地 ~/.arcrun-rag/collector.log(帶輪替),托盤跑掛不用猜。
lw := s.logWriter()
// stderr:落 log + 留末行當錯誤脈絡
go func() {
sc := bufio.NewScanner(stderr)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
_, _ = lw.Write([]byte("[stderr] " + line + "\n"))
s.mu.Lock()
s.status.LastError = line
s.mu.Unlock()
}
}()
// 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 雜訊:吞掉剩餘(仍經 tee 落 log)、跳出(行程仍由 Wait 收)
io.Copy(io.Discard, tee)
break
}
at := parseAt(r.At)
s.setState(func(st *Status) {
st.State = StateWatching
st.Rounds++
if !at.IsZero() {
st.LastRoundAt = at
}
})
}
return cmd.Wait()
}
func parseAt(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
return time.Time{}
}