feat(t104): 多帳號同時看守——切換概念消滅

leo 架構依據:「它只是一個門,幾個帳號通過它同步並沒有影響」+
「我不是 Google Drive……不提供暫存空間,daemon 工作輕巧,多帳號只是頁簽問題」
(D-daemon-not-Drive)。
Accounts[] 每帳號獨立連線+資料夾;舊 config 冪等遷移 accounts[0];
逐帳號同步一敗不擋全;status.json 分帳;托盤每帳號一分組;
「連上知識庫」→「+新增帳號…」(append 非替換);t86 切換清空退役;
t101 刪除作用於正確帳號。+873/-149、collector 5+tray 5 新測試,
兩模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 16:45:14 +08:00
parent f8450815d3
commit bf1f0d9991
5 changed files with 871 additions and 147 deletions
+269 -86
View File
@@ -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;完整勾選 UItask 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"` // t92collector 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"` // t92collector 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 可達才 truefalse 不判定(靜默)
// 頂層雲端欄位(向後相容,單帳號時有值)
CloudVersion string `json:"cloud_version,omitempty"`
CloudCheckOK bool `json:"cloud_check_ok"`
// t104per-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)
// t104append 新帳號或更新同 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 時再加一行縮短的 hostfyne 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
// t104per-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,
// t55leo 2026-07-26 實撞:「沒有 exit 的選項,我無法關閉它」):
// 沒有出口的 app 是設計缺陷——用戶只剩「強制結束」可用,而托盤 app 連 Dock 都沒有
// LSUIElement),右鍵結束那條路也不存在。這裡給一條明確的出路:
// 先停子行程(collector),再退整個 app,不留孤兒行程。
fyne.NewMenuItem("結束 Arcrun RAG", func() {
sup.Stop()
a.Quit()
+135
View File
@@ -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] 剩餘應是 /folder2got %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 > hostt26 延伸)。
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])
}
}
}
+173 -52
View File
@@ -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.<acct>.workers.dev
Namespace string `json:"namespace"` // 租戶 namespace(必填),如 demo
APIKey string `json:"api_key"` // X-Arcrun-API-Key(空=沿用 namespacedemo 慣例
Email string `json:"email,omitempty"` // 實例主身分(t26;人人記得自己的 email,CF 全程隱形
InstanceName string `json:"instance_name,omitempty"` // 暱稱(t26 選配;不取就顯示 email
Library string `json:"library"` // 藏書地圖歸庫鍵(空=kb;per-folder 未指定時的後備
// t52leo 2026-07-25 裁決:「資料夾=庫」——企業有 10 個資料夾,財務/人事不准任何人看,
// 全塞一個庫=找死):每個看守資料夾對應自己的庫。key=資料夾絕對路徑,value=庫名。
// 未列出的資料夾=用資料夾名 slug 當庫名(librarySlug);再不行才退回 Library。
// t104:多帳號清單(新制)。有值時頂層連線欄位僅保留讀取相容——
// 啟動時若無 Accounts 但有舊的頂層 CypherURLLoadDirectConfig 自動包成 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_directextractor 模式不用
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.ClaudeBinin-memory,呼叫端存檔)。
// - 每輪結束寫 ~/.arcrun-rag/status.jsont91 狀態可見性)。
// - 每輪結束寫 ~/.arcrun-rag/status.jsont91 狀態可見性,含 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)
// t103per-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)
}
+276 -3
View File
@@ -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) {
}
}
// 兩欄都空=缺必填
// t104cypher_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=%dresults=%+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 %sgot results: %+v", hostA, results)
}
if countB == 0 {
t.Errorf("② account B 結果應標 host %sgot results: %+v", hostB, results)
}
// 各自的 fake server 收到請求(a.md → serverAb.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 應為 1got %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 hostgot %+v", r)
}
}
}
func TestExpandHomeEdgeCases(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
+18 -6
View File
@@ -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 可達才為 truefalse 代表網路失敗,不判定過舊
// 頂層 cloud 欄位保留向後相容(單帳號時同時填頂層+AccountDetails
CloudVersion string `json:"cloud_version,omitempty"` // bundle_version(單帳號時填
CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true
// t104per-account 狀態(key = instanceHostOf(cypher_url)
AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"`
}
// ExtractFail 記一筆萃取失敗(路徑+白話原因)。