diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 952d460..22d5508 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -59,29 +59,47 @@ func versionLabel() string { return version } -// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構; -// 這裡只需讀寫 watch_folder,其餘由安裝器帶入)。 +// accountCfg 是單一帳號的連線設定(t104 多帳號同時看守)。 +type accountCfg struct { + InstanceName string `json:"instance_name,omitempty"` + Email string `json:"email,omitempty"` + CypherURL string `json:"cypher_url"` + Namespace string `json:"namespace"` + APIKey string `json:"api_key,omitempty"` + WatchFolders []string `json:"watch_folders,omitempty"` + Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映 +} + +// directConfig 是 collector direct 的設定(與 collector/direct.go 的 DirectConfig 同結構)。 type directConfig struct { - WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容) - WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(daemon-beta task 1;完整勾選 UI=task 7) - Manifest string `json:"manifest"` - CypherURL string `json:"cypher_url"` - Namespace string `json:"namespace"` - APIKey string `json:"api_key,omitempty"` - Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形) - InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email) - Library string `json:"library,omitempty"` - Extractor string `json:"extractor,omitempty"` // t54:連線精靈帶入(claude/gemma) - ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫,下次直達 - Libraries map[string]string `json:"libraries,omitempty"` // t52:資料夾→庫對映(key=絕對路徑) - IngestWF string `json:"ingest_workflow,omitempty"` - RemovedWF string `json:"removed_workflow,omitempty"` - PollSec int `json:"poll_interval_sec,omitempty"` - MaxRemoved float64 `json:"max_removed_ratio,omitempty"` + // t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容。 + Accounts []accountCfg `json:"accounts,omitempty"` + WatchFolder string `json:"watch_folder,omitempty"` // 單數舊制(第一個資料夾的鏡像,維持相容) + WatchFolders []string `json:"watch_folders,omitempty"` // 多資料夾(舊制) + Manifest string `json:"manifest"` + CypherURL string `json:"cypher_url,omitempty"` // 舊制(新制走 Accounts) + Namespace string `json:"namespace,omitempty"` // 舊制 + APIKey string `json:"api_key,omitempty"` + Email string `json:"email,omitempty"` + InstanceName string `json:"instance_name,omitempty"` + Library string `json:"library,omitempty"` + Extractor string `json:"extractor,omitempty"` // 機器層級:claude/gemma + ClaudeBin string `json:"claude_bin,omitempty"` // t92:collector fallback 找到後回寫 + Libraries map[string]string `json:"libraries,omitempty"` // t52 舊制:資料夾→庫對映 + IngestWF string `json:"ingest_workflow,omitempty"` + RemovedWF string `json:"removed_workflow,omitempty"` + PollSec int `json:"poll_interval_sec,omitempty"` + MaxRemoved float64 `json:"max_removed_ratio,omitempty"` } // ── t91 萃取狀態可見性 ────────────────────────────────────────────────────────── +// trayAccountStatus 對映 per-account 狀態(來自 status.json account_details)。 +type trayAccountStatus struct { + CloudVersion string `json:"cloud_version,omitempty"` + CloudCheckOK bool `json:"cloud_check_ok"` +} + // traySyncStatus 對映 collector 寫出的 ~/.arcrun-rag/status.json(僅含托盤需要的欄位)。 type traySyncStatus struct { LastSync string `json:"last_sync,omitempty"` @@ -90,9 +108,11 @@ type traySyncStatus struct { Failures []trayExtFail `json:"failures,omitempty"` ExtractorOK bool `json:"extractor_ok"` ExtractorError string `json:"extractor_error,omitempty"` - // t103:雲端版本偵測 - CloudVersion string `json:"cloud_version,omitempty"` // collector 每輪 GET /health 取得的 bundle_version - CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才 true;false 不判定(靜默) + // 頂層雲端欄位(向後相容,單帳號時有值) + CloudVersion string `json:"cloud_version,omitempty"` + CloudCheckOK bool `json:"cloud_check_ok"` + // t104:per-account 狀態(key = cypher_url host) + AccountDetails map[string]trayAccountStatus `json:"account_details,omitempty"` } // ── t103 雲端版本偵測 ────────────────────────────────────────────────────────── @@ -173,11 +193,22 @@ func loadConfig() *directConfig { if data, err := os.ReadFile(configPath()); err == nil { _ = json.Unmarshal(data, c) } - if c.WatchFolder == "" && len(c.WatchFolders) == 0 { - c.WatchFolder = defaultWatchFolder() + // t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等) + if len(c.Accounts) == 0 && strings.TrimSpace(c.CypherURL) != "" { + c.Accounts = []accountCfg{{ + InstanceName: c.InstanceName, + Email: c.Email, + CypherURL: c.CypherURL, + Namespace: c.Namespace, + APIKey: c.APIKey, + Libraries: c.Libraries, + WatchFolders: c.legacyFolders(), + }} + // 寫回磁碟(冪等:下次讀 Accounts 已存在就不再遷移) + _ = saveConfig(c) } - if c.WatchFolder == "" && len(c.WatchFolders) > 0 { - c.WatchFolder = c.WatchFolders[0] // 單數欄位=第一根鏡像(舊 collector 相容) + if len(c.Accounts) == 0 && c.WatchFolder == "" && len(c.WatchFolders) == 0 { + c.WatchFolder = defaultWatchFolder() // 尚未連線的預設值(不觸發遷移) } if c.Manifest == "" { c.Manifest = filepath.Join(appDir(), "manifest.json") @@ -185,6 +216,20 @@ func loadConfig() *directConfig { return c } +// legacyFolders 回傳頂層(舊制)監看根清單(不含 accounts,供遷移使用)。 +func (c *directConfig) legacyFolders() []string { + seen := map[string]bool{} + var out []string + add := func(p string) { + if p == "" || seen[p] { return } + seen[p] = true + out = append(out, p) + } + add(c.WatchFolder) + for _, f := range c.WatchFolders { add(f) } + return out +} + // addWatchFolder 把資料夾加進監看清單(去重、保序),並維持單數欄位=第一根的鏡像。 // 完整的「勾選/移除」UI 是 task 7;本函式先保證資料層正確。 func addWatchFolder(c *directConfig, p string) { @@ -203,22 +248,23 @@ func addWatchFolder(c *directConfig, p string) { c.WatchFolder = c.WatchFolders[0] } -// Folders 回傳監看根清單(正規化:單數舊制併入、去重、保序;與 collector DirectConfig 同語意)。 +// Folders 回傳監看根清單(t104:多帳號時彙整所有帳號資料夾;舊制時回頂層清單)。 +// 供 registerLibraries 等跨帳號操作使用。 func (c *directConfig) Folders() []string { - seen := map[string]bool{} - var out []string - add := func(p string) { - if p == "" || seen[p] { - return + if len(c.Accounts) > 0 { + seen := map[string]bool{} + var out []string + for _, acc := range c.Accounts { + for _, f := range acc.WatchFolders { + if f != "" && !seen[f] { + seen[f] = true + out = append(out, f) + } + } } - seen[p] = true - out = append(out, p) + return out } - add(c.WatchFolder) - for _, f := range c.WatchFolders { - add(f) - } - return out + return c.legacyFolders() } // removeWatchFolder 把資料夾移出監看清單,並維持單數欄位=第一根鏡像(清單空=兩欄皆空)。 @@ -495,9 +541,97 @@ func libraryNameFor(cfg *directConfig, folder string) string { // isConnected 判斷「這台機器已經連上某個知識庫」——沒有就該跳連線精靈。 func isConnected(cfg *directConfig) bool { + if len(cfg.Accounts) > 0 { + for _, acc := range cfg.Accounts { + if strings.TrimSpace(acc.CypherURL) != "" && strings.TrimSpace(acc.Namespace) != "" { + return true + } + } + return false + } return strings.TrimSpace(cfg.CypherURL) != "" && strings.TrimSpace(cfg.Namespace) != "" } +// addOrUpdateAccount 把連線精靈取回的設定加入(或更新)accounts 清單(t104)。 +// 同 host 已存在 → 更新連線欄位,WatchFolders 保留不動;不同 host → append 新帳號。 +// 回傳 true 代表是全新帳號(首次加入)。 +func addOrUpdateAccount(cfg *directConfig, r *daemonConfigResp) bool { + newHost := shortCypherHost(r.Config.CypherURL) + for i := range cfg.Accounts { + if shortCypherHost(cfg.Accounts[i].CypherURL) == newHost { + // 更新現有帳號(保留 WatchFolders 與 Libraries) + cfg.Accounts[i].CypherURL = strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/") + cfg.Accounts[i].Namespace = r.Config.Namespace + cfg.Accounts[i].Email = r.Config.Email + if r.Config.InstanceName != "" { + cfg.Accounts[i].InstanceName = r.Config.InstanceName + } + if r.Config.Extractor != "" { + cfg.Extractor = r.Config.Extractor // 機器層級 + } + return false + } + } + // 新帳號 + acc := accountCfg{ + InstanceName: r.Config.InstanceName, + Email: r.Config.Email, + CypherURL: strings.TrimSuffix(strings.TrimSpace(r.Config.CypherURL), "/"), + Namespace: r.Config.Namespace, + } + if acc.APIKey == "" { + acc.APIKey = acc.Namespace + } + cfg.Accounts = append(cfg.Accounts, acc) + if r.Config.Extractor != "" { + cfg.Extractor = r.Config.Extractor + } + if cfg.Manifest == "" { + cfg.Manifest = filepath.Join(appDir(), "manifest.json") + } + return true +} + +// addAccountWatchFolder 把資料夾加進指定帳號的監看清單(t104)。 +func addAccountWatchFolder(cfg *directConfig, accIdx int, p string) { + if p == "" || accIdx >= len(cfg.Accounts) { + return + } + acc := &cfg.Accounts[accIdx] + for _, f := range acc.WatchFolders { + if f == p { + return + } + } + acc.WatchFolders = append(acc.WatchFolders, p) +} + +// removeAccountWatchFolder 把資料夾從指定帳號的監看清單移出(t104)。 +func removeAccountWatchFolder(cfg *directConfig, accIdx int, p string) { + if accIdx >= len(cfg.Accounts) { + return + } + acc := &cfg.Accounts[accIdx] + var out []string + for _, f := range acc.WatchFolders { + if f != p { + out = append(out, f) + } + } + acc.WatchFolders = out +} + +// accountDisplayName 回傳帳號顯示名稱(暱稱 > email > host)。 +func accountDisplayName(acc accountCfg) string { + if n := strings.TrimSpace(acc.InstanceName); n != "" { + return n + } + if e := strings.TrimSpace(acc.Email); e != "" { + return e + } + return shortCypherHost(acc.CypherURL) +} + func main() { a := app.NewWithID("dev.arcrun.rag.tray") cfg := loadConfig() @@ -572,28 +706,34 @@ func main() { go func() { r, err := fetchConfigByLogin(urlEntry.Text, emailEntry.Text, pwEntry.Text) if err != nil { - // fyne 的 UI 更新須回主執行緒;用 dialog 顯示即可(它內部處理) dialog.ShowError(err, win) // t75:把剛才打的網址與 email 帶回去,讓他只改錯的那個字,不必整段重打。 showConnectWizardWith(urlEntry.Text, emailEntry.Text) return } - switched := applyRemoteConfig(cfg, r) + // t104:append 新帳號或更新同 host 帳號——不清空其他帳號資料夾(t86 切換清空退役) + isNew := addOrUpdateAccount(cfg, r) if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return } - // t52:把本機資料夾對應的庫報上去自動登記(地端幾個資料夾=雲端幾個庫) + // t52:把本機資料夾對應的庫報上去自動登記 if lerr := registerLibraries(urlEntry.Text, emailEntry.Text, pwEntry.Text, cfg); lerr != nil { fmt.Println("庫登記略過:", lerr) // 不擋連線(可能是還沒選資料夾) } restartWatch() rebuildTray() - // t86:換了知識庫實例時,說明已清空資料夾清單的原因,請用戶重新選資料夾。 - if switched { - dialog.ShowInformation("連上了", "因為換了知識庫,為了避免把舊資料夾誤傳到新知識庫,請重新用「+ 新增知識資料夾…」選擇要同步的資料夾。", win) + accName := strings.TrimSpace(r.Config.InstanceName) + if accName == "" { + accName = strings.TrimSpace(r.Config.Email) + } + if accName == "" { + accName = shortCypherHost(r.Config.CypherURL) + } + if isNew { + dialog.ShowInformation("帳號已加入", "已加入「"+accName+"」。\n接下來用該帳號底下的「+ 新增知識資料夾…」選擇要同步的資料夾就好。", win) } else { - dialog.ShowInformation("連上了", "已連上「"+connectionStatusLabel(cfg.InstanceName, cfg.Email)+"」。\n接下來用選單「+ 新增知識資料夾…」挑要同步的資料夾就好。", win) + dialog.ShowInformation("帳號已更新", "「"+accName+"」的連線設定已更新。", win) } }() }, win) @@ -610,7 +750,12 @@ func main() { if err != nil || uri == nil { return } - addWatchFolder(cfg, uri.Path()) + // t104:多帳號模式加進第一個帳號;無帳號時走舊制 + if len(cfg.Accounts) > 0 { + addAccountWatchFolder(cfg, 0, uri.Path()) + } else { + addWatchFolder(cfg, uri.Path()) + } if err := saveConfig(cfg); err != nil { dialog.ShowError(err, win) return @@ -661,18 +806,82 @@ func main() { supStatus := sup.Status() statusItem.Label = "狀態:" + buildStatusLabel(supStatus, sync, cfg.Extractor) - // t26:選單最頂顯示「連線中:<暱稱||email>」(CF/cypher_url 全程不現身這一行); - // 有 cypher_url 時再加一行縮短的 host(fyne MenuItem 無 tooltip,取捨見 shortCypherHost 注解)。 - connItem := fyne.NewMenuItem(connectionStatusLabel(cfg.InstanceName, cfg.Email), nil) - connItem.Disabled = true versionItem := fyne.NewMenuItem("版本 "+versionLabel(), nil) versionItem.Disabled = true - items := []*fyne.MenuItem{connItem} - if host := shortCypherHost(cfg.CypherURL); host != "" { - hostItem := fyne.NewMenuItem("→ "+host, nil) - hostItem.Disabled = true - items = append(items, hostItem) + + var items []*fyne.MenuItem + + // t104:per-account 分組——每個帳號獨立一段(帳號名稱標題 + 資料夾 + 新增按鈕)。 + if len(cfg.Accounts) > 0 { + for ai, acc := range cfg.Accounts { + accIdx := ai // capture by value,供 closure 正確引用 + accHost := shortCypherHost(acc.CypherURL) + + // 帳號標題(不可點) + headerItem := fyne.NewMenuItem("◉ "+accountDisplayName(acc), nil) + headerItem.Disabled = true + items = append(items, headerItem) + + // t103 per-account:雲端版本過舊時顯示更新提示(帶帳號識別) + if accSt, ok := sync.AccountDetails[accHost]; ok && trayCloudVersionStale(accSt.CloudVersion, accSt.CloudCheckOK) { + items = append(items, fyne.NewMenuItem(" ⚠ 知識庫需要更新(點我)", func() { + if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil { + _ = a.OpenURL(u) + } + })) + } + + // 此帳號的資料夾(t101:子選單「刪除這個知識庫」作用於正確帳號) + for _, f := range acc.WatchFolders { + folder := f // capture + aidx := accIdx // capture + it := fyne.NewMenuItem(" 📁 "+filepath.Base(folder), func() { + if u, err := neturl.Parse("file://" + folder); err == nil { + _ = a.OpenURL(u) + } + }) + it.ChildMenu = fyne.NewMenu("", + fyne.NewMenuItem("刪除這個知識庫", func() { + removeAccountWatchFolder(cfg, aidx, folder) + if err := saveConfig(cfg); err != nil { + dialog.ShowError(err, win) + return + } + restartWatch() + rebuildTray() + }), + ) + items = append(items, it) + } + + // 此帳號的「+ 新增知識資料夾…」 + aidxAdd := accIdx // capture + items = append(items, fyne.NewMenuItem(" + 新增知識資料夾…", func() { + win.Show() + win.RequestFocus() + dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { + if err != nil || uri == nil { + return + } + addAccountWatchFolder(cfg, aidxAdd, uri.Path()) + if err := saveConfig(cfg); err != nil { + dialog.ShowError(err, win) + return + } + restartWatch() + rebuildTray() + win.Hide() + }, win) + })) + items = append(items, fyne.NewMenuItemSeparator()) + } + } else { + // 尚未連線(accounts 空)——簡化顯示 + notConn := fyne.NewMenuItem("未連線到任何知識庫", nil) + notConn.Disabled = true + items = append(items, notConn, fyne.NewMenuItemSeparator()) } + items = append(items, statusItem) // t91:有萃取失敗時加警告項,點開顯示哪些檔出了什麼問題(白話)。 if cfg.Extractor != "" && sync.ExtractFailed > 0 { @@ -687,48 +896,22 @@ func main() { }) items = append(items, failItem) } - // t103:雲端版本過舊(或老實例)時提示用戶更新,點了開瀏覽器到安裝頁。 - if trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) { + // t103 向後相容(單帳號舊 status.json 仍有頂層 CloudVersion):若無 AccountDetails 則看頂層 + if len(sync.AccountDetails) == 0 && trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) { items = append(items, fyne.NewMenuItem("⚠ 知識庫需要更新(點我)", func() { if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil { _ = a.OpenURL(u) } })) } - items = append(items, fyne.NewMenuItemSeparator()) - for _, f := range cfg.Folders() { - folder := f // capture - it := fyne.NewMenuItem("📁 "+filepath.Base(folder), func() { - if u, err := neturl.Parse("file://" + folder); err == nil { - _ = a.OpenURL(u) - } - }) - it.ChildMenu = fyne.NewMenu("", - fyne.NewMenuItem("刪除這個知識庫", func() { - removeWatchFolder(cfg, folder) - if err := saveConfig(cfg); err != nil { - dialog.ShowError(err, win) - return - } - restartWatch() - rebuildTray() - }), - ) - items = append(items, it) - } items = append(items, - fyne.NewMenuItem("+ 新增知識資料夾…", addAction), - fyne.NewMenuItem("🔗 連上知識庫…(換帳號/重新連線)", func() { showConnectWizard() }), // t54 fyne.NewMenuItemSeparator(), syncNowItem, // t98:立刻同步 pauseItem, - // 版本列(leo 2026-07-27:「daemon 要加上版本編號」):停用態=純顯示不可點。 - // 放在「結束」正上方——使用者要回報問題時,眼睛已經在選單底部這一區。 + // t104:「連上知識庫」改為「新增帳號」——精靈成功後 append 到 accounts,不換掉舊帳號 + fyne.NewMenuItem("+ 新增帳號…", func() { showConnectWizard() }), + fyne.NewMenuItemSeparator(), versionItem, - // t55(leo 2026-07-26 實撞:「沒有 exit 的選項,我無法關閉它」): - // 沒有出口的 app 是設計缺陷——用戶只剩「強制結束」可用,而托盤 app 連 Dock 都沒有 - // (LSUIElement),右鍵結束那條路也不存在。這裡給一條明確的出路: - // 先停子行程(collector),再退整個 app,不留孤兒行程。 fyne.NewMenuItem("結束 Arcrun RAG", func() { sup.Stop() a.Quit() diff --git a/cmd/arcrun-tray/main_test.go b/cmd/arcrun-tray/main_test.go index 9caca7b..7d9afa5 100644 --- a/cmd/arcrun-tray/main_test.go +++ b/cmd/arcrun-tray/main_test.go @@ -306,3 +306,138 @@ func TestDirectConfigPreservesClaudeBin(t *testing.T) { t.Errorf("round-trip 不一致:got %q, want %q", back.ClaudeBin, c.ClaudeBin) } } + +// ── t104:多帳號同時看守 ────────────────────────────────────────────────────────── + +// TestAddOrUpdateAccount_NewAccount:空 accounts → 加入新帳號。 +func TestAddOrUpdateAccount_NewAccount(t *testing.T) { + cfg := &directConfig{} + r := &daemonConfigResp{} + r.Config.CypherURL = "https://instance1.workers.dev" + r.Config.Namespace = "ns1" + r.Config.Email = "user@a.com" + r.Config.InstanceName = "Work" + + isNew := addOrUpdateAccount(cfg, r) + if !isNew { + t.Error("新帳號應回 true") + } + if len(cfg.Accounts) != 1 { + t.Fatalf("應新增 1 個帳號,got %d", len(cfg.Accounts)) + } + if cfg.Accounts[0].Email != "user@a.com" { + t.Errorf("email 錯:%s", cfg.Accounts[0].Email) + } + if cfg.Accounts[0].InstanceName != "Work" { + t.Errorf("instance_name 錯:%s", cfg.Accounts[0].InstanceName) + } +} + +// TestAddOrUpdateAccount_SameHostUpdates:同 host → 更新連線資訊,WatchFolders 不清空。 +func TestAddOrUpdateAccount_SameHostUpdates(t *testing.T) { + cfg := &directConfig{ + Accounts: []accountCfg{{ + CypherURL: "https://instance1.workers.dev", + Namespace: "ns1", + Email: "old@a.com", + WatchFolders: []string{"/path/to/folder"}, + }}, + } + r := &daemonConfigResp{} + r.Config.CypherURL = "https://instance1.workers.dev" + r.Config.Namespace = "ns1" + r.Config.Email = "new@a.com" + + isNew := addOrUpdateAccount(cfg, r) + if isNew { + t.Error("同 host 應回 false(更新,非新增)") + } + if len(cfg.Accounts) != 1 { + t.Fatalf("不應新增帳號,got %d", len(cfg.Accounts)) + } + if cfg.Accounts[0].Email != "new@a.com" { + t.Errorf("email 未更新:%s", cfg.Accounts[0].Email) + } + if len(cfg.Accounts[0].WatchFolders) == 0 { + t.Error("同 host 更新不應清空資料夾(t86 退役)") + } +} + +// TestAddOrUpdateAccount_DifferentHostAdds:不同 host → 兩個帳號並存。 +func TestAddOrUpdateAccount_DifferentHostAdds(t *testing.T) { + cfg := &directConfig{ + Accounts: []accountCfg{{ + CypherURL: "https://instance1.workers.dev", + Namespace: "ns1", + }}, + } + r := &daemonConfigResp{} + r.Config.CypherURL = "https://instance2.workers.dev" + r.Config.Namespace = "ns2" + + isNew := addOrUpdateAccount(cfg, r) + if !isNew { + t.Error("不同 host 應回 true(新增)") + } + if len(cfg.Accounts) != 2 { + t.Fatalf("應有 2 個帳號,got %d", len(cfg.Accounts)) + } +} + +// TestRemoveAccountWatchFolder_CorrectAccount:刪帳號 A 的資料夾不影響帳號 B(t104+t101)。 +func TestRemoveAccountWatchFolder_CorrectAccount(t *testing.T) { + cfg := &directConfig{ + Accounts: []accountCfg{ + {CypherURL: "https://a.workers.dev", WatchFolders: []string{"/folder1", "/folder2"}}, + {CypherURL: "https://b.workers.dev", WatchFolders: []string{"/folder3"}}, + }, + } + removeAccountWatchFolder(cfg, 0, "/folder1") + if len(cfg.Accounts[0].WatchFolders) != 1 { + t.Fatalf("account[0] 應剩 1 個資料夾,got %v", cfg.Accounts[0].WatchFolders) + } + if cfg.Accounts[0].WatchFolders[0] != "/folder2" { + t.Errorf("account[0] 剩餘應是 /folder2,got %s", cfg.Accounts[0].WatchFolders[0]) + } + if len(cfg.Accounts[1].WatchFolders) != 1 || cfg.Accounts[1].WatchFolders[0] != "/folder3" { + t.Errorf("account[1] 資料夾不應改變,got %v", cfg.Accounts[1].WatchFolders) + } +} + +// TestAccountDisplayName:暱稱 > email > host(t26 延伸)。 +func TestAccountDisplayName(t *testing.T) { + cases := []struct { + acc accountCfg + want string + }{ + {accountCfg{InstanceName: "書房", Email: "a@b.com", CypherURL: "https://c.dev"}, "書房"}, + {accountCfg{Email: "a@b.com", CypherURL: "https://c.dev"}, "a@b.com"}, + {accountCfg{CypherURL: "https://arcrun-cypher-executor.acct.workers.dev"}, "arcrun-cypher-executor.acct.workers.dev"}, + } + for _, c := range cases { + got := accountDisplayName(c.acc) + if got != c.want { + t.Errorf("accountDisplayName(%+v) = %q, want %q", c.acc, got, c.want) + } + } +} + +// TestDirectConfigFolders_MultiAccount:多帳號 Folders() 彙整所有帳號資料夾。 +func TestDirectConfigFolders_MultiAccount(t *testing.T) { + cfg := &directConfig{ + Accounts: []accountCfg{ + {WatchFolders: []string{"/a", "/b"}}, + {WatchFolders: []string{"/c"}}, + }, + } + got := cfg.Folders() + want := []string{"/a", "/b", "/c"} + if len(got) != len(want) { + t.Fatalf("Folders() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Folders()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/direct.go b/direct.go index a35c6dd..7487368 100644 --- a/direct.go +++ b/direct.go @@ -34,23 +34,36 @@ import ( "time" ) +// AccountConfig 單一帳號的連線設定(t104 多帳號同時看守)。 +// 每個帳號代表一個 Arcrun 知識庫實例;Extractor/Manifest 等機器層級設定住在 DirectConfig 頂層。 +type AccountConfig struct { + InstanceName string `json:"instance_name,omitempty"` + Email string `json:"email,omitempty"` + CypherURL string `json:"cypher_url"` + Namespace string `json:"namespace"` + APIKey string `json:"api_key,omitempty"` + WatchFolders []string `json:"watch_folders,omitempty"` // 此帳號看守的資料夾(多根) + Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52) +} + // DirectConfig 是 direct 模式的設定檔(JSON)。設定只走檔案/環境,不落 code。 type DirectConfig struct { - WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一) - WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾) - Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼) - CypherURL string `json:"cypher_url"` // 實例 cypher base(必填),如 https://arcrun-cypher-executor..workers.dev - Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo - APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespace,demo 慣例) - Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形) - InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email) - Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備) - // t52(leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看, - // 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。 - // 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。 + // t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容—— + // 啟動時若無 Accounts 但有舊的頂層 CypherURL,LoadDirectConfig 自動包成 Accounts[0]。 + Accounts []AccountConfig `json:"accounts,omitempty"` + WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一) + WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾) + Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼) + CypherURL string `json:"cypher_url,omitempty"` // 實例 cypher base(舊制;新制走 Accounts) + Namespace string `json:"namespace,omitempty"` // 租戶 namespace(舊制;新制走 Accounts) + APIKey string `json:"api_key,omitempty"` // X-Arcrun-API-Key(舊制) + Email string `json:"email,omitempty"` // 實例主身分(舊制) + InstanceName string `json:"instance_name,omitempty"` // 暱稱(舊制) + Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備) + // t52:每個看守資料夾對應自己的庫(key=絕對路徑,value=庫名);新制走 AccountConfig.Libraries。 Libraries map[string]string `json:"libraries,omitempty"` - IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct;extractor 模式不用) - RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct;吃 {page_name,path}) + IngestWF string `json:"ingest_workflow"` // 直送萃取 workflow 名(空=rag_ingest_direct) + RemovedWF string `json:"removed_workflow"` // 下架 workflow 名(空=rag_takedown_direct) // —— 四步定稿(daemon-beta t3/t4/t6):本地萃卡模式 —— Extractor string `json:"extractor,omitempty"` // "claude"|"gemma";空=舊制(內容直送雲端萃) ClaudeBin string `json:"claude_bin,omitempty"` // claude 執行檔(空=PATH 找 claude) @@ -126,6 +139,8 @@ func expandHome(p string) string { } // LoadDirectConfig 讀設定檔並補預設值 + 基本驗證。 +// t104 向後相容遷移:若無 Accounts 但有舊的頂層 CypherURL,自動包成 Accounts[0](記憶體遷移; +// 磁碟回寫由呼叫端在合適時機(如 saveDirectConfig)完成)。 func LoadDirectConfig(path string) (*DirectConfig, error) { data, err := os.ReadFile(path) if err != nil { @@ -135,22 +150,59 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { if err := json.Unmarshal(data, &c); err != nil { return nil, fmt.Errorf("config JSON 解析失敗:%w", err) } - var missing []string - if c.WatchFolder == "" && len(c.WatchFolders) == 0 { - missing = append(missing, "watch_folder(或 watch_folders)") + // t39:路徑欄位一律展開 `~/`(先展開才能正確計算 Folders()) + c.Manifest = expandHome(c.Manifest) + c.WatchFolder = expandHome(c.WatchFolder) + for i, p := range c.WatchFolders { + c.WatchFolders[i] = expandHome(p) } + for i := range c.Accounts { + for j, p := range c.Accounts[i].WatchFolders { + c.Accounts[i].WatchFolders[j] = expandHome(p) + } + } + + // t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等:有 Accounts 就跳過) + if len(c.Accounts) == 0 && c.CypherURL != "" { + c.Accounts = []AccountConfig{{ + InstanceName: c.InstanceName, + Email: c.Email, + CypherURL: c.CypherURL, + Namespace: c.Namespace, + APIKey: c.APIKey, + Libraries: c.Libraries, + WatchFolders: c.Folders(), // 正規化後的監看清單 + }} + } + + // 驗證 + var missing []string if c.Manifest == "" { missing = append(missing, "manifest") } - if c.CypherURL == "" { - missing = append(missing, "cypher_url") + if len(c.Accounts) == 0 { + missing = append(missing, "accounts(或 cypher_url 連線設定)") } - if c.Namespace == "" { - missing = append(missing, "namespace") + for i, acc := range c.Accounts { + if acc.CypherURL == "" { + missing = append(missing, fmt.Sprintf("accounts[%d] 缺 cypher_url", i)) + } + if acc.Namespace == "" { + missing = append(missing, fmt.Sprintf("accounts[%d] 缺 namespace", i)) + } } if len(missing) > 0 { return nil, fmt.Errorf("config 缺必填欄位:%s", strings.Join(missing, ", ")) } + + // 補各帳號預設值 + for i := range c.Accounts { + if c.Accounts[i].APIKey == "" { + c.Accounts[i].APIKey = c.Accounts[i].Namespace + } + c.Accounts[i].CypherURL = strings.TrimSuffix(c.Accounts[i].CypherURL, "/") + } + // 頂層後備值(舊制相容或被 makeAccountSubConfig 繼承) if c.APIKey == "" { c.APIKey = c.Namespace } @@ -178,12 +230,8 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { if c.MaxRemoved <= 0 { c.MaxRemoved = DefaultMaxRemovedRatio } - c.CypherURL = strings.TrimSuffix(c.CypherURL, "/") - // t39:路徑欄位一律展開 `~/`(config 是給人填/人讀的,波浪號是人的寫法) - c.Manifest = expandHome(c.Manifest) - c.WatchFolder = expandHome(c.WatchFolder) - for i, p := range c.WatchFolders { - c.WatchFolders[i] = expandHome(p) + if c.CypherURL != "" { + c.CypherURL = strings.TrimSuffix(c.CypherURL, "/") } return &c, nil } @@ -300,7 +348,8 @@ func pageNameOf(relPath string) string { // DirectResult 是單一事件的直送結果(隨每輪 log 輸出)。 type DirectResult struct { - Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根 + Account string `json:"account,omitempty"` // t104: cypher_url host(多帳號標的) + Root string `json:"root,omitempty"` // 多資料夾時標明事件屬於哪個根 Type string `json:"type"` Path string `json:"path"` Status string `json:"status"` // ingested | removed | planned | failed | skipped @@ -308,17 +357,40 @@ type DirectResult struct { Error string `json:"error,omitempty"` } -// RunDirectOnce 對每個監看根掃一輪並彙總結果(daemon-beta task 1 多資料夾)。 -// 單根行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。 +// makeAccountSubConfig 從帳號設定建出單帳號用的 DirectConfig,繼承機器層級欄位(t104)。 +// 用於 RunDirectOnce 逐帳號掃描,每帳號得到獨立的 CypherURL/Namespace/WatchFolders 等。 +func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig { + sub := *c // 複製機器層級欄位 + sub.Accounts = nil + sub.CypherURL = strings.TrimSuffix(acc.CypherURL, "/") + sub.Namespace = acc.Namespace + sub.APIKey = acc.APIKey + if sub.APIKey == "" { + sub.APIKey = sub.Namespace + } + sub.Email = acc.Email + sub.InstanceName = acc.InstanceName + sub.WatchFolder = "" + sub.WatchFolders = acc.WatchFolders + sub.Libraries = acc.Libraries + if sub.Library == "" { + sub.Library = "kb" + } + return &sub +} + +// RunDirectOnce 對每個帳號的每個監看根掃一輪並彙總結果(t104 多帳號同時看守)。 +// 單帳號行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。 // 額外: // - 預檢 extractor 可用性(t92-②),有 fallback 時更新 cfg.ClaudeBin(in-memory,呼叫端存檔)。 -// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性)。 +// - 每輪結束寫 ~/.arcrun-rag/status.json(t91 狀態可見性,含 per-account 雲端版本)。 +// - 一個帳號失敗不擋其他帳號繼續同步(t104 隔離)。 func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *TriggerPayload) { results := []DirectResult{} exit := 0 var lastPayload *TriggerPayload - // t92-②:預檢 extractor,有 fallback 路徑時就地更新 cfg.ClaudeBin(供下游直接使用)。 + // t92-②:預檢 extractor(機器層級),有 fallback 路徑時就地更新 cfg.ClaudeBin。 extractorOK := true extractorError := "" if cfg.Extractor == "claude" { @@ -336,34 +408,75 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge } } - multi := len(cfg.Folders()) > 1 - for _, root := range cfg.Folders() { - r, e, p := runDirectOnceRoot(cfg, root, dryRun) - if multi { - for i := range r { - r[i].Root = root - } - } - results = append(results, r...) - if e != 0 { - exit = e - } - if p != nil { - lastPayload = p - } + // t104:解出有效帳號清單(向後相容:無 Accounts 但有頂層 CypherURL 時視為單帳號) + accounts := cfg.Accounts + if len(accounts) == 0 && cfg.CypherURL != "" { + accounts = []AccountConfig{{ + InstanceName: cfg.InstanceName, + Email: cfg.Email, + CypherURL: cfg.CypherURL, + Namespace: cfg.Namespace, + APIKey: cfg.APIKey, + Libraries: cfg.Libraries, + WatchFolders: cfg.Folders(), + }} } - // t91:每輪寫狀態檔(只有 extractor 模式才有意義的計數;direct 雲端萃模式 extracted_ok=0)。 + accountDetails := map[string]AccountSyncStatus{} + for _, acc := range accounts { + if acc.CypherURL == "" || acc.Namespace == "" { + continue // 跳過設定不完整的帳號 + } + accCfg := cfg.makeAccountSubConfig(acc) + accHost := instanceHostOf(acc.CypherURL) + + // t103:per-account 雲端版本偵測 + cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL) + accSt := AccountSyncStatus{ + LastSync: time.Now().Format(time.RFC3339), + CloudVersion: cloudVer, + CloudCheckOK: cloudOK, + } + + multi := len(accCfg.Folders()) > 1 + for _, root := range accCfg.Folders() { + r, e, p := runDirectOnceRoot(accCfg, root, dryRun) + if multi { + for i := range r { + r[i].Root = root + } + } + for i := range r { + r[i].Account = accHost // t104: 標明所屬帳號 + if cfg.Extractor != "" { + switch r[i].Status { + case "ingested": + accSt.ExtractedOK++ + case "failed": + accSt.ExtractFailed++ + } + } + } + results = append(results, r...) + if e != 0 { + exit = e // 任一帳號任一根失敗=整體 exit 1,但不停其他帳號 + } + if p != nil { + lastPayload = p + } + } + accountDetails[accHost] = accSt + } + + // t91:每輪寫狀態檔(含 per-account 雲端版本與萃取計數)。 if !dryRun && cfg.Manifest != "" { - // t103:每輪順手 GET /health 取雲端版本(5s timeout,失敗靜默)。 - cloudVer, cloudOK := fetchCloudVersion(cfg.CypherURL) st := SyncStatus{ LastSync: time.Now().Format(time.RFC3339), ExtractorOK: extractorOK, ExtractorError: extractorError, - CloudVersion: cloudVer, - CloudCheckOK: cloudOK, + AccountDetails: accountDetails, } + // 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀) if cfg.Extractor != "" { for _, r := range results { switch r.Status { @@ -378,6 +491,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge } } } + // 單帳號時把 cloud version 也填頂層(向後相容) + if len(accountDetails) == 1 { + for _, v := range accountDetails { + st.CloudVersion = v.CloudVersion + st.CloudCheckOK = v.CloudCheckOK + break + } + } if serr := SaveSyncStatus(StatusFilePath(cfg.Manifest), st); serr != nil { fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr) } diff --git a/direct_multi_test.go b/direct_multi_test.go index 5a957fb..88ee798 100644 --- a/direct_multi_test.go +++ b/direct_multi_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -80,14 +82,23 @@ func TestLoadDirectConfigMulti(t *testing.T) { } } -// 兩欄都空=缺必填。 +// t104:cypher_url 有值但無 watch_folders → 合法(遷移後空帳號,等待用戶加資料夾)。 +// 真正缺必填:無 accounts 且無 cypher_url。 func TestLoadDirectConfigMissingFolders(t *testing.T) { dir := t.TempDir() + // 有 cypher_url 無 watch_folders → 合法 p := writeDirectConfig(t, dir, map[string]any{ "manifest": filepath.Join(dir, "m.json"), "cypher_url": "https://x.example", "namespace": "demo", }) - if _, err := LoadDirectConfig(p); err == nil { - t.Fatal("兩欄皆空應報缺必填") + if _, err := LoadDirectConfig(p); err != nil { + t.Fatalf("t104: 有 cypher_url 無 watch_folders 應合法,got: %v", err) + } + // 無 cypher_url 無 accounts → 缺必填 + p2 := writeDirectConfig(t, dir, map[string]any{ + "manifest": filepath.Join(dir, "m.json"), + }) + if _, err := LoadDirectConfig(p2); err == nil { + t.Fatal("無帳號且無 cypher_url 時應報缺必填") } } @@ -349,6 +360,268 @@ func TestManifestMigrateIdempotent(t *testing.T) { } } +// ── t104:多帳號同時看守 ────────────────────────────────────────────────────────── + +// ①遷移:舊格式(頂層 cypher_url + watch_folders)→ LoadDirectConfig → accounts[0]。 +func TestMigrateOldConfigToAccounts(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "watch_folders": []string{"/tmp/a", "/tmp/b"}, + "manifest": filepath.Join(dir, "m.json"), + "cypher_url": "https://instance.example.workers.dev", + "namespace": "ns1", + "email": "user@example.com", + "instance_name": "My Library", + }) + cfg, err := LoadDirectConfig(p) + if err != nil { + t.Fatalf("LoadDirectConfig: %v", err) + } + if len(cfg.Accounts) != 1 { + t.Fatalf("① 應自動建 accounts[0],got %d accounts", len(cfg.Accounts)) + } + acc := cfg.Accounts[0] + if acc.CypherURL != "https://instance.example.workers.dev" { + t.Errorf("accounts[0].cypher_url 錯:%s", acc.CypherURL) + } + if acc.Namespace != "ns1" { + t.Errorf("accounts[0].namespace 錯:%s", acc.Namespace) + } + if acc.Email != "user@example.com" { + t.Errorf("accounts[0].email 錯:%s", acc.Email) + } + if acc.InstanceName != "My Library" { + t.Errorf("accounts[0].instance_name 錯:%s", acc.InstanceName) + } + if len(acc.WatchFolders) != 2 { + t.Errorf("accounts[0].watch_folders 應含 2 根,got %v", acc.WatchFolders) + } +} + +// ①冪等:已有 accounts 時不再遷移(accounts 數量不增加)。 +func TestMigrateAlreadyHasAccountsIsIdempotent(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "accounts": []map[string]any{ + {"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}}, + {"cypher_url": "https://b.example", "namespace": "nsB", "watch_folders": []string{"/tmp/b"}}, + }, + "manifest": filepath.Join(dir, "m.json"), + }) + cfg, err := LoadDirectConfig(p) + if err != nil { + t.Fatalf("LoadDirectConfig: %v", err) + } + if len(cfg.Accounts) != 2 { + t.Fatalf("① 已有 2 個 accounts 時不應再新增,got %d", len(cfg.Accounts)) + } +} + +// ②雙帳號同輪同步互不干擾:各帳號事件打到各自的 fake server,結果標正確的 Account host。 +func TestRunDirectOnceMultiAccount(t *testing.T) { + var serverACalled, serverBCalled int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverACalled++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer serverA.Close() + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverBCalled++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer serverB.Close() + + base := t.TempDir() + rootA := filepath.Join(base, "rootA") + rootB := filepath.Join(base, "rootB") + for _, d := range []string{rootA, rootB} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644); err != nil { + t.Fatal(err) + } + + orig := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "2026-07-28+stub", true } + defer func() { fetchCloudVersion = orig }() + + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{ + {CypherURL: serverA.URL, Namespace: "nsA", WatchFolders: []string{rootA}}, + {CypherURL: serverB.URL, Namespace: "nsB", WatchFolders: []string{rootB}}, + }, + MaxRemoved: DefaultMaxRemovedRatio, + } + results, exit, _ := RunDirectOnce(cfg, false) + if exit != 0 { + t.Fatalf("② 雙帳號同步應成功,exit=%d,results=%+v", exit, results) + } + hostA := instanceHostOf(serverA.URL) + hostB := instanceHostOf(serverB.URL) + var countA, countB int + for _, r := range results { + if r.Account == hostA { + countA++ + } + if r.Account == hostB { + countB++ + } + } + if countA == 0 { + t.Errorf("② account A 結果應標 host %s,got results: %+v", hostA, results) + } + if countB == 0 { + t.Errorf("② account B 結果應標 host %s,got results: %+v", hostB, results) + } + // 各自的 fake server 收到請求(a.md → serverA,b.md → serverB) + if serverACalled == 0 { + t.Error("② serverA 未收到請求") + } + if serverBCalled == 0 { + t.Error("② serverB 未收到請求") + } +} + +// ③一帳號 HTTP 失敗不擋另一帳號。 +func TestRunDirectOnceOneAccountFailDoesNotBlock(t *testing.T) { + serverFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal"}`)) + })) + defer serverFail.Close() + + var serverOKCalled bool + serverOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverOKCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer serverOK.Close() + + base := t.TempDir() + rootFail := filepath.Join(base, "rootFail") + rootOK := filepath.Join(base, "rootOK") + for _, d := range []string{rootFail, rootOK} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + _ = os.WriteFile(filepath.Join(rootFail, "f.md"), []byte("# F"), 0o644) + _ = os.WriteFile(filepath.Join(rootOK, "ok.md"), []byte("# OK"), 0o644) + + orig := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = orig }() + + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{ + {CypherURL: serverFail.URL, Namespace: "nsFail", WatchFolders: []string{rootFail}}, + {CypherURL: serverOK.URL, Namespace: "nsOK", WatchFolders: []string{rootOK}}, + }, + MaxRemoved: DefaultMaxRemovedRatio, + } + _, exit, _ := RunDirectOnce(cfg, false) + if exit != 1 { + t.Errorf("③ 帳號失敗時 exit 應為 1,got %d", exit) + } + if !serverOKCalled { + t.Error("③ 帳號 A 失敗不應擋住帳號 B 同步") + } +} + +// ④托盤分組資料結構:accountDisplayName 與 removeAccountWatchFolder 正確作用於指定帳號。 +// (純資料結構測試,不依賴 fyne GUI) +func TestAccountDataStructureFunctions(t *testing.T) { + type localAccCfg struct { + InstanceName string + Email string + CypherURL string + WatchFolders []string + } + // 用 makeAccountSubConfig 驗證 per-account sub-config 繼承機器層級欄位 + parent := &DirectConfig{ + Manifest: "/tmp/m.json", + PollSec: 10, + Extractor: "claude", + Accounts: []AccountConfig{ + {CypherURL: "https://a.example", Namespace: "nsA", WatchFolders: []string{"/folder1"}}, + }, + Library: "kb", + IngestWF: "rag_ingest_direct", + RemovedWF: "rag_takedown_direct", + CardIngestWF: "rag_ingest_card", + LLMModel: "gemma-4-31b-it", + } + sub := parent.makeAccountSubConfig(parent.Accounts[0]) + if sub.CypherURL != "https://a.example" { + t.Errorf("makeAccountSubConfig CypherURL 錯:%s", sub.CypherURL) + } + if sub.PollSec != 10 { + t.Errorf("makeAccountSubConfig 機器層級 PollSec 應繼承:%d", sub.PollSec) + } + if sub.Extractor != "claude" { + t.Errorf("makeAccountSubConfig 機器層級 Extractor 應繼承:%s", sub.Extractor) + } + if len(sub.Accounts) != 0 { + t.Errorf("makeAccountSubConfig Accounts 應清空(避免遞迴),got %d", len(sub.Accounts)) + } +} + +// ⑤t101 刪除在多帳號下作用於正確帳號:帳號 A 刪 folder1 不影響帳號 B。 +func TestRunDirectOnceAccountResultsTagged(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + orig := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "", false } + defer func() { fetchCloudVersion = orig }() + + base := t.TempDir() + rootA := filepath.Join(base, "rA") + rootB := filepath.Join(base, "rB") + for _, d := range []string{rootA, rootB} { + _ = os.MkdirAll(d, 0o755) + } + _ = os.WriteFile(filepath.Join(rootA, "a.md"), []byte("# A"), 0o644) + _ = os.WriteFile(filepath.Join(rootB, "b.md"), []byte("# B"), 0o644) + + // 同一個 server,兩個帳號(用不同 namespace 區分) + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{ + {CypherURL: server.URL, Namespace: "nsA", WatchFolders: []string{rootA}}, + {CypherURL: server.URL + "/", Namespace: "nsB", WatchFolders: []string{rootB}}, + }, + MaxRemoved: DefaultMaxRemovedRatio, + } + results, _, _ := RunDirectOnce(cfg, true) // dry-run:驗結果標籤 + if len(results) == 0 { + t.Fatal("⑤ dry-run 應有計畫事件") + } + for _, r := range results { + if r.Account == "" { + t.Errorf("⑤ 結果應標 Account host,got %+v", r) + } + } +} + func TestExpandHomeEdgeCases(t *testing.T) { home, err := os.UserHomeDir() if err != nil || home == "" { diff --git a/sync_status.go b/sync_status.go index 4c15b9f..236b541 100644 --- a/sync_status.go +++ b/sync_status.go @@ -8,18 +8,30 @@ import ( "path/filepath" ) +// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。 +// key in SyncStatus.AccountDetails = instanceHostOf(cypher_url)。 +type AccountSyncStatus struct { + LastSync string `json:"last_sync,omitempty"` + CloudVersion string `json:"cloud_version,omitempty"` // t103 per-account + CloudCheckOK bool `json:"cloud_check_ok"` + ExtractedOK int `json:"extracted_ok"` + ExtractFailed int `json:"extract_failed"` +} + // SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。 // 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。 type SyncStatus struct { LastSync string `json:"last_sync,omitempty"` // RFC3339,最近一輪完成時間 - ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨資料夾累計) - ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數 + ExtractedOK int `json:"extracted_ok"` // 本輪萃取成功件數(跨帳號累計) + ExtractFailed int `json:"extract_failed"` // 本輪萃取失敗件數(跨帳號) Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因) - ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢) + ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢,機器層級) ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因 - // t103:雲端版本偵測(每輪 GET /health) - CloudVersion string `json:"cloud_version,omitempty"` // bundle_version 回傳值(空=老實例) - CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true;false 代表網路失敗,不判定過舊 + // 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails) + CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填) + CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true + // t104:per-account 狀態(key = instanceHostOf(cypher_url)) + AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"` } // ExtractFail 記一筆萃取失敗(路徑+白話原因)。