Files
arcrun-collector/supervisor/supervisor.go
T
cloud-worker 9e356e3d99 feat(daemon/p04): fyne 托盤殼 + collector 看守器(CP-1 第6關產品化,leo 定 fyne)
leo 2026-07-21 回「fyne」。交付「可交付的殼」:
- collector/supervisor(純 stdlib,無 fyne):看守 collector direct 子行程、串 stdout JSON 出
  狀態(看守中/上次同步/出錯自動重試/已暫停)、崩潰重起、Stop 同步等 loop 收掉(修 Stop→Start
  換資料夾時舊 loop 覆寫新狀態的 race)。**sandbox `go test -race ./... 全綠(6 測,含 race)**,
  既有 collector 測試零回歸。
- collector/cmd/arcrun-tray(獨立 module,隔開 fyne CGo 依賴):選單列/系統匣 icon+狀態+
  選資料夾(原生對話框)/暫停·繼續/打開資料夾;關窗縮背景;watch_folder 預設 ~/ArcrunRAG 避開
  ~/Documents 的 TCC 禁區;wires supervisor。

誠實界定(mindset §7):
- **in-process vs 子行程**:leo 偏好 in-process,但 collector 是 package main,真 in-process 需抽核心成
  library(動已驗引擎有回歸風險)→ 本版走子行程看守(collector 零改、崩潰隔離),行為對用戶相同;
  in-process 抽取列可選後續,待 leo 裁。
- **fyne GUI 沙箱編不了**(CGo+GL)→ build/簽章/TCC 授權=leo/地端真機做(紅線②③);
  fyne API 細節以首次真機 build 為準。README 附建置/簽章/TCC 交接。
2026-07-21 09:27:55 +00:00

248 lines
6.4 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/exec"
"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"`
}
// Supervisor 看守單一 collector direct 行程。
type Supervisor struct {
BinPath string // collector 執行檔路徑(托盤 app bundle 內)
ConfigPath string // direct config.json 路徑
Backoff time.Duration // 非預期退出後的重起間隔(0=預設 3s)
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
}
// 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()
}
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
}
// stderr:留末行當錯誤脈絡
go func() {
sc := bufio.NewScanner(stderr)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
s.mu.Lock()
s.status.LastError = line
s.mu.Unlock()
}
}()
// stdout:用 json.Decoder 逐個 JSON 值解(容忍 MarshalIndent 的多行)
dec := json.NewDecoder(stdout)
for {
var r round
if derr := dec.Decode(&r); derr != nil {
if derr == io.EOF {
break
}
// 非 JSON 雜訊:吞掉剩餘、跳出(行程仍由 Wait 收)
io.Copy(io.Discard, stdout)
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{}
}