From 77aa4c5195a20ba2f91edbcde35be333928ca954 Mon Sep 17 00:00:00 2001 From: richblack Date: Tue, 28 Jul 2026 15:06:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(t98):=20=E6=89=98=E7=9B=A4=E3=80=8C?= =?UTF-8?q?=E7=AB=8B=E5=88=BB=E5=90=8C=E6=AD=A5=E3=80=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=A8=8A=E8=99=9F=E6=AA=94=E6=B3=95=E9=9B=B6=20IPC=EF=BC=8C?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo:「無法知道到底同步了沒,可以在 daemon 上加一個立刻同步?」 tray 點擊寫 ~/.arcrun-rag/sync-now+短暫顯示「同步中…」;collector 主迴圈 1 秒輪詢 訊號檔、有就立刻跑一輪刪檔,無則照 PollSec 排程;完成後 status.json 更新自然刷新托盤。 測試:collector+supervisor+tray 三模組全綠(總管親跑)。 (實作=子 CC;驗證+commit=總管) --- cmd/arcrun-tray/main.go | 23 +++++++ cmd/arcrun-tray/main_test.go | 13 ++++ direct.go | 36 +++++++++-- direct_sync_now_test.go | 121 +++++++++++++++++++++++++++++++++++ sync_status.go | 6 ++ sync_status_test.go | 9 +++ 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 direct_sync_now_test.go diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 81ce5bd..9a4d5f1 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -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 要加上版本編號」):停用態=純顯示不可點。 // 放在「結束」正上方——使用者要回報問題時,眼睛已經在選單底部這一區。 diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 282a93d..a4061cb 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -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{ diff --git a/direct.go b/direct.go index e8f2519..2329f43 100644 --- a/direct.go +++ b/direct.go @@ -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 } diff --git a/direct_sync_now_test.go b/direct_sync_now_test.go new file mode 100644 index 0000000..65900b7 --- /dev/null +++ b/direct_sync_now_test.go @@ -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) + } +} diff --git a/sync_status.go b/sync_status.go index 7098c75..d8c2552 100644 --- a/sync_status.go +++ b/sync_status.go @@ -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") +} diff --git a/sync_status_test.go b/sync_status_test.go index a29a33b..3ddf908 100644 --- a/sync_status_test.go +++ b/sync_status_test.go @@ -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。