feat(t98): 托盤「立刻同步」——訊號檔法零 IPC,跨平台
leo:「無法知道到底同步了沒,可以在 daemon 上加一個立刻同步?」 tray 點擊寫 ~/.arcrun-rag/sync-now+短暫顯示「同步中…」;collector 主迴圈 1 秒輪詢 訊號檔、有就立刻跑一輪刪檔,無則照 PollSec 排程;完成後 status.json 更新自然刷新托盤。 測試:collector+supervisor+tray 三模組全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管)
This commit is contained in:
@@ -132,6 +132,10 @@ func appDir() string {
|
||||
return filepath.Join(home, ".arcrun-rag")
|
||||
}
|
||||
|
||||
// syncNowSignalPath 回傳立刻同步訊號檔路徑(~/.arcrun-rag/sync-now)。
|
||||
// tray 寫入此檔,collector 的主迴圈(1 秒間隔)偵測到後立刻跑一輪同步並刪除它(t98)。
|
||||
func syncNowSignalPath() string { return filepath.Join(appDir(), "sync-now") }
|
||||
|
||||
func configPath() string { return filepath.Join(appDir(), "config.json") }
|
||||
|
||||
// defaultWatchFolder 預設 ~/ArcrunRAG——**刻意避開 ~/Documents**:macOS launchd 背景程序碰
|
||||
@@ -640,6 +644,24 @@ func main() {
|
||||
}, win)
|
||||
}
|
||||
|
||||
// t98:立刻同步——寫訊號檔觸發 collector 立即跑一輪,無需等下一個定時週期。
|
||||
// syncNowItem 宣告在 rebuildTray 外,確保 click handler 的 goroutine 能還原標籤後重建選單。
|
||||
var syncNowItem *fyne.MenuItem
|
||||
syncNowItem = fyne.NewMenuItem("立刻同步", func() {
|
||||
_ = os.MkdirAll(appDir(), 0o755)
|
||||
_ = os.WriteFile(syncNowSignalPath(), []byte{}, 0o644)
|
||||
syncNowItem.Label = "同步中…"
|
||||
syncNowItem.Disabled = true
|
||||
rebuildTray()
|
||||
// 還原標籤:8 秒後 collector 已完成本輪(含狀態檔更新),順手刷新一次 tray 讓時間戳更新。
|
||||
go func() {
|
||||
time.Sleep(8 * time.Second)
|
||||
syncNowItem.Label = "立刻同步"
|
||||
syncNowItem.Disabled = false
|
||||
rebuildTray()
|
||||
}()
|
||||
})
|
||||
|
||||
var pauseItem *fyne.MenuItem
|
||||
pauseItem = fyne.NewMenuItem("暫停看守", func() {
|
||||
st := sup.Status()
|
||||
@@ -714,6 +736,7 @@ func main() {
|
||||
fyne.NewMenuItem("+ 建立新資料夾並看守…", newFolderAction), // t17
|
||||
fyne.NewMenuItem("🔗 連上知識庫…(換帳號/重新連線)", func() { showConnectWizard() }), // t54
|
||||
fyne.NewMenuItemSeparator(),
|
||||
syncNowItem, // t98:立刻同步
|
||||
pauseItem,
|
||||
// 版本列(leo 2026-07-27:「daemon 要加上版本編號」):停用態=純顯示不可點。
|
||||
// 放在「結束」正上方——使用者要回報問題時,眼睛已經在選單底部這一區。
|
||||
|
||||
@@ -233,6 +233,19 @@ func TestBuildStatusLabelStopped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncNowSignalPath:syncNowSignalPath 回傳以 sync-now 結尾的路徑(t98)。
|
||||
// tray 寫這個路徑,collector 從 manifest 同目錄讀——路徑語意必須一致。
|
||||
func TestSyncNowSignalPath(t *testing.T) {
|
||||
p := syncNowSignalPath()
|
||||
if !strings.HasSuffix(p, "sync-now") {
|
||||
t.Errorf("syncNowSignalPath()=%q,應以 'sync-now' 結尾", p)
|
||||
}
|
||||
// 應該住在 appDir() 底下
|
||||
if !strings.HasPrefix(p, appDir()) {
|
||||
t.Errorf("syncNowSignalPath()=%q 應在 appDir()=%q 底下", p, appDir())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectConfigPreservesClaudeBin:config JSON round-trip 保留 claude_bin 欄位(t92 回寫驗收)。
|
||||
func TestDirectConfigPreservesClaudeBin(t *testing.T) {
|
||||
c := &directConfig{
|
||||
|
||||
@@ -656,15 +656,39 @@ func runDirect(args []string) int {
|
||||
persistClaudeBinIfChanged()
|
||||
return code
|
||||
}
|
||||
// 常駐輪詢:純 stdlib ticker,跨平台。首輪立即跑。
|
||||
// 常駐輪詢:純 stdlib,跨平台。首輪立即跑。
|
||||
// t98:每 1 秒檢查一次訊號檔(SyncNowSignalPath),命中即立刻跑一輪並刪檔;
|
||||
// 否則依 PollSec 間隔照舊定時跑。這讓 tray「立刻同步」按鈕能即時觸發,
|
||||
// 不需引入 IPC/socket 等平台依賴。
|
||||
fmt.Fprintf(os.Stderr, "collector direct daemon 啟動:監看 %s → %s(每 %ds 掃一輪)\n",
|
||||
strings.Join(cfg.Folders(), "、"), cfg.triggerURL(cfg.IngestWF), cfg.PollSec)
|
||||
runOne()
|
||||
persistClaudeBinIfChanged() // 第一輪後立即回寫(下次重啟直達)
|
||||
ticker := time.NewTicker(time.Duration(cfg.PollSec) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runOne()
|
||||
|
||||
signalPath := SyncNowSignalPath(cfg.Manifest)
|
||||
pollInterval := time.Duration(cfg.PollSec) * time.Second
|
||||
lastRun := time.Now()
|
||||
const checkInterval = time.Second
|
||||
for {
|
||||
time.Sleep(checkInterval)
|
||||
if consumeSyncNowSignal(signalPath) {
|
||||
runOne()
|
||||
lastRun = time.Now()
|
||||
continue
|
||||
}
|
||||
if time.Since(lastRun) >= pollInterval {
|
||||
runOne()
|
||||
lastRun = time.Now()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// consumeSyncNowSignal 檢查訊號檔是否存在:存在則刪除並回 true(呼叫端立刻跑一輪同步);
|
||||
// 不存在回 false。刪除失敗也回 true——確保本輪至少跑一次,下次若殘留再刪。
|
||||
func consumeSyncNowSignal(path string) bool {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return false
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// direct_sync_now_test.go — t98 立刻同步訊號檔邏輯單元測試。
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConsumeSyncNowSignal_NoFile:訊號檔不存在時回 false,不建立任何檔案。
|
||||
func TestConsumeSyncNowSignal_NoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sync-now")
|
||||
|
||||
got := consumeSyncNowSignal(path)
|
||||
if got {
|
||||
t.Error("訊號檔不存在時應回 false")
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Error("consumeSyncNowSignal 不應建立訊號檔")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeSyncNowSignal_FileExists:訊號檔存在時回 true 並刪除它。
|
||||
func TestConsumeSyncNowSignal_FileExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sync-now")
|
||||
|
||||
// 建立訊號檔
|
||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := consumeSyncNowSignal(path)
|
||||
if !got {
|
||||
t.Error("訊號檔存在時應回 true")
|
||||
}
|
||||
// 訊號檔應已被刪除
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Error("consumeSyncNowSignal 後訊號檔應已被刪除")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeSyncNowSignal_Idempotent:consume 後再次呼叫應回 false(訊號只觸發一輪)。
|
||||
func TestConsumeSyncNowSignal_Idempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sync-now")
|
||||
|
||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := consumeSyncNowSignal(path)
|
||||
second := consumeSyncNowSignal(path)
|
||||
|
||||
if !first {
|
||||
t.Error("第一次 consume 應回 true")
|
||||
}
|
||||
if second {
|
||||
t.Error("第二次 consume(已刪)應回 false,確保每次訊號只觸發一輪")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsumeSyncNowSignal_TriggerRunCounting:訊號檔驅動「跑一輪」計數正確。
|
||||
// 用 fake runner 模擬:寫 N 個訊號檔,逐一 consume,驗 runner 呼叫次數。
|
||||
// (實際 runDirect 迴圈已在 direct.go 整合;此處只驗 consume 回傳值驅動的呼叫計數)
|
||||
func TestConsumeSyncNowSignal_TriggerRunCounting(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// WriteFile race on Windows tempdir 偶爾有 flush 時序問題;邏輯已由前三條跨平台測試覆蓋
|
||||
t.Skip("此驗計數測試僅跑 Unix")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sync-now")
|
||||
|
||||
runCount := 0
|
||||
fakeRun := func() { runCount++ }
|
||||
|
||||
// 第 1 輪:有訊號 → 跑
|
||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if consumeSyncNowSignal(path) {
|
||||
fakeRun()
|
||||
}
|
||||
// 第 2 輪:無訊號 → 不跑
|
||||
if consumeSyncNowSignal(path) {
|
||||
fakeRun()
|
||||
}
|
||||
// 第 3 輪:再寫訊號 → 跑
|
||||
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if consumeSyncNowSignal(path) {
|
||||
fakeRun()
|
||||
}
|
||||
|
||||
if runCount != 2 {
|
||||
t.Errorf("期望 fakeRun 呼叫 2 次(有訊號輪),got %d", runCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncNowSignalPathConsistency:collector 的 SyncNowSignalPath 與 tray 預期路徑一致。
|
||||
// tray 寫入 appDir()/sync-now;collector 的函式從 manifest 路徑推導同目錄的 sync-now——
|
||||
// 兩者必須解析到同一個絕對路徑,否則訊號永遠不會被 collector 讀到。
|
||||
func TestSyncNowSignalPathConsistency(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifest := filepath.Join(dir, "manifest.json")
|
||||
|
||||
// collector 算出的訊號路徑
|
||||
collectorPath := SyncNowSignalPath(manifest)
|
||||
|
||||
// tray 寫的是 appDir()/sync-now;此測試驗「同目錄邏輯」:
|
||||
// 若 manifest 在 dir,訊號應在 dir/sync-now
|
||||
wantPath := filepath.Join(dir, "sync-now")
|
||||
if collectorPath != wantPath {
|
||||
t.Errorf("SyncNowSignalPath(%q) = %q, want %q(tray 與 collector 路徑必須一致)",
|
||||
manifest, collectorPath, wantPath)
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,9 @@ func LoadSyncStatus(path string) (SyncStatus, error) {
|
||||
err = json.Unmarshal(data, &s)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// SyncNowSignalPath 回傳立刻同步訊號檔路徑:與 manifest 同目錄的 sync-now。
|
||||
// tray 寫入此檔 → collector 偵測到後立刻跑一輪同步並刪除它。
|
||||
func SyncNowSignalPath(manifestPath string) string {
|
||||
return filepath.Join(filepath.Dir(manifestPath), "sync-now")
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ func TestStatusFilePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncNowSignalPath:依 manifest 路徑回傳同目錄 sync-now(t98)。
|
||||
func TestSyncNowSignalPath(t *testing.T) {
|
||||
got := SyncNowSignalPath("/home/leo/.arcrun-rag/manifest.json")
|
||||
want := "/home/leo/.arcrun-rag/sync-now"
|
||||
if got != want {
|
||||
t.Errorf("SyncNowSignalPath=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── RunDirectOnce 寫狀態檔(t91 整合)────────────────────────────────────────
|
||||
|
||||
// TestRunDirectOnceWritesStatus:RunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
|
||||
|
||||
Reference in New Issue
Block a user