From 895d6721777c5281ba7606faabfef331f96c4bca Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 16 Aug 2026 21:35:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(daemon):=20=E7=A7=BB=E9=99=A4=E8=B3=87?= =?UTF-8?q?=E6=96=99=E5=A4=BE=E8=A6=81=E7=9C=9F=E7=9A=84=E6=8A=8A=E9=9B=B2?= =?UTF-8?q?=E7=AB=AF=E8=B3=87=E6=96=99=E6=94=B6=E5=9B=9E=EF=BC=88arcrun-ra?= =?UTF-8?q?g#46=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-16 實撞:掛資料夾、同步完成、從清單按「移除」之後—— 「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**」。 內容一筆都沒少,照樣搜得到、照樣 is_embedded=1、AI 照樣拿它回答。 真兇(源碼):app.go 的 RemoveFolder 全文只做三件事——從 WatchFolders 拿掉、 saveCfg、restartWatch,**一次都沒碰撤除**。而撤除機制本身是好的(在部署白名單裡、 有測試、direct.go 真的會觸發它),只是那兩個觸發點都在「還在監看的資料夾裡某個檔 被刪掉」的差異偵測迴圈裡。⇒ 刪一個檔會撤除 ✅/移除整個資料夾不會 ❌。 **在使用者眼裡是同一件事,在程式裡是兩條完全不同的路,只有一條接上了撤除。** 這不只是少一個功能:產品說明卡寫著「確保資料所有權完全屬於使用者而非 SaaS 供應商」,而使用者唯一看得到的收回動作不收回任何東西 ⇒ 知情同意的問題。 修法(沿用既有那條撤除路,不另寫一份): - drainPendingTakedowns:把既有的「待下架清單逐筆送出」抽成共用函式 - retireRootOnce:資料夾進 retiring_folders 後,把帳本裡真的上傳過的檔排進 既有的 PendingTakedowns、走同一條撤除路;撤乾淨才刪帳本 - 進度與失敗真因寫進 status.json(level-triggered),App 看到 done 才清設定 - App 是 config.json 的唯一寫入者(兩個行程都寫=互相蓋掉對方的設定) 邊界(本票最危險的地方):path 是相對於被監看資料夾的路徑 ⇒ 兩個資料夾各有 notes.md 時 page_name 與 path 完全相同,撤除一個會連坐另一個。撤除 payload 帶 library(逐根導出,與 ingest 同一個函式算的),workflow 兩個比對節點加「library 相符才殺」。只在兩邊都有 library 時才收緊 ⇒ 舊 daemon 不送/舊卡沒有都退回原行為。 畫面:舊文案「已經上傳的知識卡不會被刪除」技術上是對的,但它替使用者決定了他要的 是「只停止同步」。改成兩個選項各寫一行後果讓他選(預設待 leo 裁)。 測試:go test ./collector/... ./collector/cmd/arcrun-app/... 全綠; 新增 direct_retire_test.go(7)/remove_folder_takedown_test.go(4)/ workflows/tests/takedown-scope.test.mjs(8)。UI 用真 dist + headless Chrome 複驗, check-cis.sh/check-render.sh 全過。 ◐ 未做:真實例端到端(不可逆且 leo 正在該機器上工作,步驟已寫成清單等總管確認)/ workflow 要重新部署才生效/未重打 bundle、未出貨。 Co-Authored-By: Claude Opus 5 --- cmd/arcrun-app/app.go | 113 ++++- cmd/arcrun-app/frontend/src/main.js | 30 +- cmd/arcrun-app/frontend/src/style.css | 3 + cmd/arcrun-app/remove_folder_takedown_test.go | 151 +++++++ direct.go | 269 ++++++++++-- direct_retire_test.go | 404 ++++++++++++++++++ sync_status.go | 16 + 7 files changed, 941 insertions(+), 45 deletions(-) create mode 100644 cmd/arcrun-app/remove_folder_takedown_test.go create mode 100644 direct_retire_test.go diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index e415e35..1d141f0 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -60,8 +60,14 @@ type accountCfg struct { Namespace string `json:"namespace"` APIKey string `json:"api_key,omitempty"` WatchFolders []string `json:"watch_folders,omitempty"` - Extractor string `json:"extractor,omitempty"` - GeminiAPIKey string `json:"gemini_api_key,omitempty"` + // RetiringFolders=已按「移除並收回」、雲端還沒撤乾淨的資料夾(arcrun-rag#46)。 + // + // 🔴 t108 那條教訓的直接適用:saveCfg 會把整個 accounts 陣列**用這個 struct 重新序列化** + // ⇒ 這裡少一個欄位,寫在 config 裡的那一欄下次存檔就靜默消失(Go omitempty 直接不見)。 + // 凡是 collector 的 AccountConfig 有、而 App 會改到的欄位,兩份必須鏡像。 + RetiringFolders []string `json:"retiring_folders,omitempty"` + Extractor string `json:"extractor,omitempty"` + GeminiAPIKey string `json:"gemini_api_key,omitempty"` } type directConfig struct { @@ -101,6 +107,9 @@ type syncStatus struct { // 直接原樣接住,不重新定義一份會漂移的結構。key = instanceHostOf(cypher_url) // (與 UIAccount.Host 同一套算法,見 shortHost)。 AccountDetails map[string]collector.AccountSyncStatus `json:"account_details,omitempty"` + // arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。 + // 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。 + Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"` } type skippedDoc struct { @@ -219,6 +228,10 @@ func saveCfg(c *directConfig) error { type UIFolder struct { Path string `json:"path"` AccIdx int `json:"accIdx"` + // arcrun-rag#46:這個資料夾已經被移除,正在把雲端的資料收回來。 + Retiring bool `json:"retiring,omitempty"` + RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆 + RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫) } type UIAccount struct { Name string `json:"name"` @@ -469,11 +482,32 @@ func (a *App) GetState() UIState { } sync := loadSyncStatus() + + // arcrun-rag#46:collector 說收乾淨了的資料夾,這裡才真的從設定裡消失 + // (App 是 config.json 的唯一寫入者,見 pruneFinishedRetirements)。 + if pruneFinishedRetirements(cfg, sync) { + if err := saveCfg(cfg); err != nil { + appLog("清理已收回的資料夾失敗:%v", err) + } else { + restartWatch() + } + } + for i, acc := range cfg.Accounts { ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email} for _, f := range acc.WatchFolders { ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i}) } + // 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失, + // 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。 + for _, f := range acc.RetiringFolders { + uf := UIFolder{Path: f, AccIdx: i, Retiring: true} + if st, ok := sync.Retiring[f]; ok { + uf.RetireRemaining = st.Remaining + uf.RetireError = st.LastError + } + ui.Folders = append(ui.Folders, uf) + } // t215:per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost), // 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。 if accSt, ok := sync.AccountDetails[ui.Host]; ok { @@ -647,6 +681,14 @@ func (a *App) AddFolder(accIdx int, path string) error { return nil // 已經在看守了,不重複加 } } + // arcrun-rag#46:正在收回中的資料夾不能同時又加回來看守——那會變成 + // 「一邊撤除、一邊重新上傳同一批檔」,兩條路互相打架,結果不可預測。 + // 擋一次比事後對帳容易解釋,訊息要告訴使用者現在是什麼狀況、該怎麼辦。 + for _, f := range cfg.Accounts[accIdx].RetiringFolders { + if f == path { + return fmt.Errorf("這個資料夾正在從雲端收回資料,等它收完再加回來(可在畫面上看到進度)") + } + } cfg.Accounts[accIdx].WatchFolders = append(cfg.Accounts[accIdx].WatchFolders, path) sort.Strings(cfg.Accounts[accIdx].WatchFolders) if err := saveCfg(cfg); err != nil { @@ -656,7 +698,28 @@ func (a *App) AddFolder(accIdx int, path string) error { return nil } -func (a *App) RemoveFolder(accIdx int, path string) error { +// RemoveFolder 把資料夾從清單移除。 +// +// 🔴 arcrun-rag#46(leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後, +// +// **採集的 wiki 沒消失**。」——移除之後那個資料夾的內容在雲端一筆都沒少, +// 照樣搜得到、照樣是已嵌入狀態、AI 照樣拿它回答。 +// +// 真兇:這支函式原本只做三件事(從 WatchFolders 拿掉、存檔、重啟看守), +// **一次都沒碰撤除**。撤除的能力本身是好的、有測試、也真的被部署,只是 +// 「整個資料夾從清單移除」這條路從來不呼叫它—— +// **在使用者眼裡是同一件事(我不要這份資料了),在程式裡是兩條完全不同的路。** +// +// takedown=使用者在對話框上明確選的那一個: +// - true :連同雲端已經整理好的知識一起收回(資料夾搬進 retiring_folders, +// 由 collector 逐筆撤除;進度與失敗原因走 status.json 回到畫面) +// - false:只停止同步,雲端保留(=這支函式原本的行為) +// +// 為什麼做成使用者選、而不是我們替他決定:兩種都是合理的需求(換電腦/重整資料夾 +// vs 我不要這份資料了),而**猜錯任何一邊都是不可逆的**——猜「保留」則產品承諾的 +// 「資料所有權完全屬於使用者」是假的;猜「收回」則整理好的知識被誤刪。 +// ⇒ 在動作的當下把兩個後果講清楚、讓他自己挑(見前端 confirmRemove 的文案)。 +func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error { cfg, err := loadCfg() if err != nil { return err @@ -665,19 +728,63 @@ func (a *App) RemoveFolder(accIdx int, path string) error { return fmt.Errorf("找不到這個知識庫帳號") } keep := []string{} + found := false for _, f := range cfg.Accounts[accIdx].WatchFolders { if f != path { keep = append(keep, f) + } else { + found = true } } cfg.Accounts[accIdx].WatchFolders = keep + if takedown && found { + // 只在「本來真的在看守」時排撤除——否則重複按會排出一堆重複待辦。 + already := false + for _, f := range cfg.Accounts[accIdx].RetiringFolders { + if f == path { + already = true + } + } + if !already { + cfg.Accounts[accIdx].RetiringFolders = append(cfg.Accounts[accIdx].RetiringFolders, path) + } + } if err := saveCfg(cfg); err != nil { return err } restartWatch() + if takedown { + // 不必等下一輪輪詢——使用者剛按下按鈕,他期待「現在就開始」。 + // 沿用既有的 sync-now 訊號檔,不新發明一套 IPC。 + _ = a.SyncNow() + } return nil } +// pruneFinishedRetirements 把「collector 已回報收乾淨」的資料夾從設定裡清掉。 +// +// 為什麼由 App 清而不是 collector 自己清:config.json 的寫入者只有 App 一個, +// 兩個行程都寫同一個檔=互相蓋掉對方的設定(t108 那類靜默掉欄位的病的另一種形狀)。 +// collector 只在 status.json 說「這個根收乾淨了」,且**每輪都照現況重說** +// (level-triggered)——App 關著沒看到也不會卡住,下次開起來照樣清得掉。 +func pruneFinishedRetirements(cfg *directConfig, sync syncStatus) bool { + changed := false + for i := range cfg.Accounts { + keep := cfg.Accounts[i].RetiringFolders[:0:0] + for _, f := range cfg.Accounts[i].RetiringFolders { + if st, ok := sync.Retiring[f]; ok && st.Done { + changed = true + continue + } + keep = append(keep, f) + } + if len(keep) != len(cfg.Accounts[i].RetiringFolders) { + cfg.Accounts[i].RetiringFolders = keep + } + } + return changed +} + // SetAI 存 AI 設定。 // 🔴 t190:金鑰**無條件以輸入框為準**(清空=刪除)——leo 實撞過「金鑰刪不掉」。 func (a *App) SetAI(useGemini bool, key string) error { diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index d07dcf2..1e10031 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -268,7 +268,14 @@ function pageLib(s, idx) {
${kbVersionLine(a)}
- ${(a.folders || []).map((f) => ` + ${(a.folders || []).map((f) => f.retiring ? ` +
+ ${esc(f.path)} + ${f.retireError + ? '收回時出錯,會自動再試' + : `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`} +
+ ${f.retireError ? `
${esc(f.retireError)}
` : ''}` : `
${esc(f.path)} 自動同步中 @@ -467,15 +474,30 @@ async function addFolder(accIdx) { state = await go.GetState(); renderNav(); renderPage(); } +// 移除資料夾=兩個後果完全不同的動作,所以給兩顆按鈕,不給一顆猜。 +// +// 🔴 arcrun-rag#46(leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後, +// **採集的 wiki 沒消失**。」舊文案寫的是「已經上傳的知識卡不會被刪除」—— +// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」, +// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。 +// 現在兩個選擇都擺出來、後果各寫一行,由他挑。 function confirmRemove(accIdx, path) { openSheet(`

移除這個資料夾?

-

「${esc(path)}」不再自動同步。
已經上傳的知識卡不會被刪除。

-
`, +

「${esc(path)}」要怎麼處理?你電腦裡的原始檔案不會被動到,兩個選擇的差別只在雲端。

+ + +
`, () => { $('c1').onclick = closeSheet; $('c2').onclick = async () => { - await go.RemoveFolder(accIdx, path); closeSheet(); + const mode = document.querySelector('input[name="rmMode"]:checked'); + const takedown = !mode || mode.value === 'takedown'; + await go.RemoveFolder(accIdx, path, takedown); closeSheet(); state = await go.GetState(); renderNav(); renderPage(); }; }); diff --git a/cmd/arcrun-app/frontend/src/style.css b/cmd/arcrun-app/frontend/src/style.css index 6029e4b..690e022 100644 --- a/cmd/arcrun-app/frontend/src/style.css +++ b/cmd/arcrun-app/frontend/src/style.css @@ -140,6 +140,9 @@ button.ghost:hover { color: var(--err); background: transparent; } } .folder .path { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .folder .tag { font-size: 12px; color: var(--ok); white-space: nowrap; } +/* arcrun-rag#46:收回中=進行中的狀態,不是「一切正常」,所以不用綠色。 */ +.folder .tag.retiring { color: rgba(var(--ink-rgb), .5); } +.folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; } .big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; } .kv { display: flex; gap: 26px; margin-top: 4px; } diff --git a/cmd/arcrun-app/remove_folder_takedown_test.go b/cmd/arcrun-app/remove_folder_takedown_test.go new file mode 100644 index 0000000..d8ea5a1 --- /dev/null +++ b/cmd/arcrun-app/remove_folder_takedown_test.go @@ -0,0 +1,151 @@ +// remove_folder_takedown_test.go — arcrun-rag#46:按下「移除」之後,資料真的要被收回。 +// +// leo 2026-08-16 實撞:「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**。」 +// 真兇:RemoveFolder 只把路徑從 WatchFolders 拿掉、存檔、重啟看守——**一次都沒碰撤除**。 +// +// 本檔釘 App 這一半的三件事: +// 1. 選了「連同雲端一起收回」⇒ 資料夾要進 retiring_folders(collector 靠它才知道要撤) +// 2. 選了「只停止同步」⇒ 行為與從前一字不差(不可以偷偷幫使用者刪東西) +// 3. collector 回報收乾淨了 ⇒ 設定裡那一筆才消失(且中途不會被靜默丟掉) +package main + +import ( + "encoding/json" + "os" + "testing" + + collector "arcrun-rag/collector" +) + +func newTestCfgWithFolder(t *testing.T, path string) { + t.Helper() + cfg := &directConfig{ + Accounts: []accountCfg{{ + CypherURL: "https://example.workers.dev", Namespace: "abc123", APIKey: "abc123", + WatchFolders: []string{path, "/other/folder"}, + }}, + Extractor: "workers-ai", + } + if err := saveCfg(cfg); err != nil { + t.Fatalf("存檔失敗:%v", err) + } +} + +// ① 選「連同雲端一起收回」:離開看守清單、進入待撤清單。 +func TestRemoveFolderWithTakedownQueuesRetirement(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + newTestCfgWithFolder(t, "/kb/logseq-plugin") + + if err := (&App{}).RemoveFolder(0, "/kb/logseq-plugin", true); err != nil { + t.Fatalf("移除失敗:%v", err) + } + cfg, err := loadCfg() + if err != nil { + t.Fatal(err) + } + for _, f := range cfg.Accounts[0].WatchFolders { + if f == "/kb/logseq-plugin" { + t.Error("移除後不該還在看守清單裡") + } + } + if len(cfg.Accounts[0].RetiringFolders) != 1 || cfg.Accounts[0].RetiringFolders[0] != "/kb/logseq-plugin" { + t.Fatalf("🔴 這就是 #46 的真兇:移除沒有排任何撤除,retiring_folders=%v", + cfg.Accounts[0].RetiringFolders) + } + // 別的資料夾不能被波及 + if len(cfg.Accounts[0].WatchFolders) != 1 || cfg.Accounts[0].WatchFolders[0] != "/other/folder" { + t.Errorf("其他資料夾被動到了:%v", cfg.Accounts[0].WatchFolders) + } + + // 🔴 t108 鏡像檢查:欄位要真的落在磁碟上、且 collector 讀得回來。 + // (兩份 struct 各自維護,少一欄就會在下次存檔靜默消失。) + raw, _ := os.ReadFile(configPath()) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + accs, _ := m["accounts"].([]any) + if len(accs) == 0 { + t.Fatal("accounts 不見了") + } + a0, _ := accs[0].(map[string]any) + if _, ok := a0["retiring_folders"]; !ok { + t.Error("retiring_folders 沒有寫進磁碟 ⇒ collector 永遠不會知道要撤除") + } + cc, err := collector.LoadDirectConfig(configPath()) + if err != nil { + t.Fatalf("collector 讀不了 App 存的 config:%v", err) + } + if got := cc.RetiringRoots(); len(got) != 1 || got[0] != "/kb/logseq-plugin" { + t.Errorf("collector 端看到的待撤清單=%v", got) + } +} + +// ② 選「只停止同步」:行為與從前一字不差——不可以幫使用者做他沒選的刪除。 +func TestRemoveFolderWithoutTakedownKeepsCloudData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + newTestCfgWithFolder(t, "/kb/keep-cloud") + + if err := (&App{}).RemoveFolder(0, "/kb/keep-cloud", false); err != nil { + t.Fatalf("移除失敗:%v", err) + } + cfg, _ := loadCfg() + for _, f := range cfg.Accounts[0].WatchFolders { + if f == "/kb/keep-cloud" { + t.Error("移除後不該還在看守清單裡") + } + } + if len(cfg.Accounts[0].RetiringFolders) != 0 { + t.Errorf("使用者選的是「保留雲端資料」,不該排撤除:%v", cfg.Accounts[0].RetiringFolders) + } +} + +// ③ 收乾淨了才從設定裡消失;還在撤的中途不可以被清掉(清掉=待辦永久遺失)。 +func TestRetirementClearedOnlyWhenCollectorSaysDone(t *testing.T) { + cfg := &directConfig{Accounts: []accountCfg{{ + RetiringFolders: []string{"/kb/finished", "/kb/still-going", "/kb/never-reported"}, + }}} + + sync := syncStatus{Retiring: map[string]collector.RetiringStatus{ + "/kb/finished": {Done: true}, + "/kb/still-going": {Remaining: 12, LastError: "HTTP 500:boom"}, + // "/kb/never-reported" 刻意沒被回報(collector 還沒跑到) + }} + + if !pruneFinishedRetirements(cfg, sync) { + t.Fatal("有一筆已完成,應回報有變動") + } + got := cfg.Accounts[0].RetiringFolders + if len(got) != 2 || got[0] != "/kb/still-going" || got[1] != "/kb/never-reported" { + t.Fatalf("只有 done 的那一筆該消失,實得 %v", got) + } + // 冪等:再跑一次不該有變動(不然每輪都在寫 config) + if pruneFinishedRetirements(cfg, sync) { + t.Error("沒有新的完成項時不該回報變動") + } +} + +// ④ 收回中的資料夾不准同時加回來看守——一邊撤一邊傳,結果不可預測。 +func TestAddFolderRefusedWhileRetiring(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + cfg := &directConfig{Accounts: []accountCfg{{ + CypherURL: "https://example.workers.dev", Namespace: "abc123", + RetiringFolders: []string{"/kb/retiring"}, + }}} + if err := saveCfg(cfg); err != nil { + t.Fatal(err) + } + if err := (&App{}).AddFolder(0, "/kb/retiring"); err == nil { + t.Error("正在收回的資料夾應該擋下來並說明原因,不該默默加回去") + } + after, _ := loadCfg() + if len(after.Accounts[0].WatchFolders) != 0 { + t.Errorf("被擋下就不該寫進看守清單:%v", after.Accounts[0].WatchFolders) + } +} diff --git a/direct.go b/direct.go index 05cc95c..4fb53a9 100644 --- a/direct.go +++ b/direct.go @@ -24,6 +24,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -46,6 +47,11 @@ type AccountConfig struct { APIKey string `json:"api_key,omitempty"` WatchFolders []string `json:"watch_folders,omitempty"` // 此帳號看守的資料夾(多根) Libraries map[string]string `json:"libraries,omitempty"` // 資料夾→庫對映(t52) + // RetiringFolders=使用者已經按下「移除並收回雲端資料」、但雲端還沒撤乾淨的資料夾 + // (arcrun-rag#46)。**App 是唯一寫入者**:它把路徑從 WatchFolders 搬到這裡; + // collector 只讀,撤乾淨後在 status.json 把該根標成 done,由 App 負責清掉這一筆。 + // 一個寫入者=不會有兩個行程互相蓋掉對方的設定。 + RetiringFolders []string `json:"retiring_folders,omitempty"` // t126:每帳號獨立的萃取設定(空值繼承 DirectConfig 頂層) Extractor string `json:"extractor,omitempty"` GeminiAPIKey string `json:"gemini_api_key,omitempty"` @@ -59,6 +65,8 @@ type DirectConfig struct { Accounts []AccountConfig `json:"accounts,omitempty"` WatchFolder string `json:"watch_folder,omitempty"` // 監看的知識資料夾(單數舊制;與 watch_folders 至少填一) WatchFolders []string `json:"watch_folders,omitempty"` // 監看的知識資料夾清單(daemon-beta task 1 多資料夾) + // RetiringFolders=舊制(單帳號)的「移除並收回中」清單;新制走 Accounts[].RetiringFolders。 + RetiringFolders []string `json:"retiring_folders,omitempty"` Manifest string `json:"manifest"` // manifest JSON 路徑(必填;多資料夾時為基底名,每根一份帶尾碼) CypherURL string `json:"cypher_url,omitempty"` // 實例 cypher base(舊制;新制走 Accounts) Namespace string `json:"namespace,omitempty"` // 租戶 namespace(舊制;新制走 Accounts) @@ -187,6 +195,11 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { for j, p := range c.Accounts[i].WatchFolders { c.Accounts[i].WatchFolders[j] = expandHome(p) } + // arcrun-rag#46:待撤資料夾同樣要展開 `~/`——它與 WatchFolders 是同一種東西 + // (使用者選的路徑),漏掉這裡就會去找一個字面上叫 "~" 的資料夾(t39 那個病)。 + for j, p := range c.Accounts[i].RetiringFolders { + c.Accounts[i].RetiringFolders[j] = expandHome(p) + } } // t104:舊格式遷移——頂層 CypherURL → Accounts[0](冪等:有 Accounts 就跳過) @@ -199,6 +212,10 @@ func LoadDirectConfig(path string) (*DirectConfig, error) { APIKey: c.APIKey, Libraries: c.Libraries, WatchFolders: c.Folders(), // 正規化後的監看清單 + // arcrun-rag#46:待撤清單也要一起遷過來。漏掉這裡=舊制使用者的 + // retiring_folders 停在頂層、而 makeAccountSubConfig 會用帳號層覆蓋掉它 + // ⇒ 撤除永遠不會發生,而且**沒有任何錯誤訊息**(同 t149 的形狀)。 + RetiringFolders: c.RetiringRoots(), }} } @@ -347,6 +364,30 @@ func (c *DirectConfig) Folders() []string { return out } +// RetiringRoots 回傳「已移除、雲端撤除進行中」的根清單(arcrun-rag#46)。 +// 正規化方式與 Folders() 一致(頂層舊制+Accounts 新制、去重、保序)——刻意照抄 +// 而不是只讀 Accounts:t149 的病就是「只讀了其中一層」,一整個新制設定被靜默忽略。 +func (c *DirectConfig) RetiringRoots() []string { + seen := map[string]bool{} + var out []string + add := func(p string) { + if p == "" || seen[p] { + return + } + seen[p] = true + out = append(out, p) + } + for _, p := range c.RetiringFolders { + add(expandHome(p)) + } + for _, a := range c.Accounts { + for _, p := range a.RetiringFolders { + add(expandHome(p)) + } + } + return out +} + // instanceHostOf extracts the host from a CypherURL to use as a per-instance // distinguisher in manifest paths (t86b). Falls back to the full URL if parsing fails. func instanceHostOf(cypherURL string) string { @@ -466,6 +507,9 @@ func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig { sub.InstanceName = acc.InstanceName sub.WatchFolder = "" sub.WatchFolders = acc.WatchFolders + // arcrun-rag#46:撤除中的資料夾同樣逐帳號隔離——撤除要打的是**這個帳號**的雲端實例, + // 沿用頂層清單會把 A 帳號的待撤資料夾拿去 B 帳號打(同 WatchFolders 的道理)。 + sub.RetiringFolders = acc.RetiringFolders sub.Libraries = acc.Libraries if sub.Library == "" { sub.Library = "kb" @@ -575,6 +619,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge APIKey: cfg.APIKey, Libraries: cfg.Libraries, WatchFolders: cfg.Folders(), + // arcrun-rag#46:舊制(單帳號、頂層欄位)的待撤資料夾也要帶進來, + // 否則只有新制 accounts[] 的人撤得掉——t149 那個「只讀了其中一層」的病。 + RetiringFolders: cfg.RetiringRoots(), }} } @@ -589,6 +636,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge var totalProgress SyncProgress var stuckReasons []string + // arcrun-rag#46:這一輪各個「移除並收回中」資料夾的進度(key=資料夾路徑)。 + // 每輪重建、照現況重報(level-triggered),App 看到 done 才把設定裡那一筆清掉。 + var retiring map[string]RetiringStatus + // t215:全域「雲端最新版」只抓一次(自帶節流,見 cloud_latest.go)—— // 這是所有帳號共用的同一把尺,不是逐帳號各打一次。 latestRelease, latestOK := FetchLatestCloudRelease() @@ -684,6 +735,30 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge } } + // arcrun-rag#46:把「使用者按了移除並收回」的資料夾撤乾淨。 + // 放在看守資料夾之後:正在用的資料夾優先,收回是善後。 + for _, root := range accCfg.RetiringRoots() { + r, e, remaining, done := retireRootOnce(accCfg, root, dryRun) + for i := range r { + r[i].Root = root + r[i].Account = accHost + } + results = append(results, r...) + if e != 0 { + exit = e + } + rs := RetiringStatus{Remaining: remaining, Done: done} + for _, x := range r { + if x.Status == "failed" && x.Error != "" { + rs.LastError = shortError(x.Error) // 最後一筆失敗的真因——不然畫面只會說「還在收回」 + } + } + if retiring == nil { + retiring = map[string]RetiringStatus{} + } + retiring[root] = rs + } + // 2026-08-07:把這輪(可能剛更新過的)額度冷卻/今天已萃份數寫回, // 供下一輪 RunDirectOnce(甚至下一次程序啟動——status.json 落地磁碟)復原。 accSt.DailyIngestedDate = todayUTC(now) @@ -710,6 +785,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge ExtractorOK: extractorOK, ExtractorError: extractorError, AccountDetails: accountDetails, + Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度 } // G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。 // 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。 @@ -878,6 +954,156 @@ func saveDirectConfig(configPath string, cfg *DirectConfig) error { return os.WriteFile(configPath, data, 0o600) } +// drainPendingTakedowns 把 manifest 的「待下架」清單逐筆送去雲端撤除,成功一筆清一筆。 +// +// 為什麼抽成共用函式(arcrun-rag#46):撤除的能力本來就存在且驗過(改名/搬移的舊路徑、 +// 被監看資料夾裡被刪掉的檔都走它),缺的只是**「整個資料夾被移除」這條路沒有呼叫它**。 +// 修法是把既有那條路叫起來,不是在別的地方再寫一份撤除邏輯——同一件事有兩份實作, +// 就會像 2026-07-24 的 source_uri 鍵那樣各自漂移,而且只有真機 e2e 才抓得到。 +// +// 🔴 payload 帶 library(arcrun-rag#46 邊界):撤除的比對鍵是 (page_name, path),而 path 是 +// **相對於根**的路徑。兩個被監看的資料夾各自有 `notes.md` 時,兩邊的 (page_name, path) 完全 +// 相同 ⇒ 撤除其中一個會連坐另一個。library 是逐根導出的(libraryFor),把它一起送上去, +// 雲端才有辦法只殺對的那一份。這與 ingest 送的 library 是**同一個函式**算出來的, +// 守 2026-07-24 那條教訓:成對操作(上架/下架)要用同一把鍵。 +func drainPendingTakedowns( + cfg *DirectConfig, m *Manifest, absRoot, resultType, failPrefix string, + pace func(), dryRun bool, saveManifest func(), +) ([]DirectResult, int) { + var results []DirectResult + exit := 0 + if len(m.PendingTakedowns) == 0 { + return results, exit + } + if dryRun { + for oldPath := range m.PendingTakedowns { + results = append(results, DirectResult{Type: resultType, Path: oldPath, Status: "planned"}) + } + return results, exit + } + for oldPath, pageName := range m.PendingTakedowns { + pace() + res := DirectResult{Type: resultType, Path: oldPath} + status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{ + "page_name": pageName, + "path": oldPath, + "library": cfg.libraryFor(absRoot), + }) + res.HTTPStatus = status + if perr != nil { + res.Status, res.Error = "failed", failPrefix+perr.Error() + exit = 1 + } else { + res.Status = "removed" + m.ClearTakedown(oldPath) + // 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理。 + if cfg.Extractor != "" { + cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName))) + if _, serr := os.Stat(cardAbs); serr == nil { + _ = os.Remove(cardAbs) + } + if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil { + results = append(results, DirectResult{ + Type: "warning", Path: oldPath, Status: "skipped", + Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(), + }) + } + } + } + saveManifest() + results = append(results, res) + } + return results, exit +} + +// retireRootOnce 對「使用者已經按下移除並收回、但雲端還沒撤乾淨」的資料夾跑一輪撤除 +// (arcrun-rag#46)。回傳結果、exit 建議、還剩幾筆沒撤成功、以及這個根是不是已經收乾淨。 +// +// 為什麼不在 App 按下按鈕的當下同步做完: +// - 一個資料夾可能有上萬筆(實據 27,164 檔),同步做=畫面凍住; +// - 雲端剛好掛掉/額度用完時,一次性的動作會**永久遺失**這些待辦—— +// 這正是 PendingTakedowns 當初存在的理由(見 manifest.go 該欄位註解)。 +// +// ⇒ App 只負責把資料夾搬進 `retiring_folders`(設定檔,App 是唯一寫入者), +// collector 每輪把它排進 manifest 的待辦清單、照既有那條撤除路慢慢送、失敗自動下輪重試。 +// 收乾淨了就刪掉該根的 manifest 檔,並在 status.json 把這個根標成 done—— +// **level-triggered**(每輪照現況重報,不是只報一次的事件),App 漏看一輪也不會卡住。 +func retireRootOnce(cfg *DirectConfig, root string, dryRun bool) ( + results []DirectResult, exit int, remaining int, done bool, +) { + absRoot, err := filepath.Abs(expandHome(root)) + if err != nil { + return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed", + Error: "解析資料夾路徑失敗:" + err.Error()}}, 1, 0, false + } + absManifest, err := filepath.Abs(cfg.manifestPathFor(absRoot)) + if err != nil { + return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed", + Error: "解析帳本路徑失敗:" + err.Error()}}, 1, 0, false + } + cfg.migrateManifestIfNeeded(absRoot, absManifest) + + // 沒有帳本=這個根從來沒同步過(或已經收乾淨了)⇒ 雲端沒有它的東西,直接算完成。 + if _, serr := os.Stat(absManifest); errors.Is(serr, os.ErrNotExist) { + return nil, 0, 0, true + } + m, err := LoadManifest(absManifest, absRoot) + if err != nil { + return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed", + Error: "讀不了這個資料夾的帳本,暫不撤除(下輪重試):" + err.Error()}}, 1, 0, false + } + + // 第一輪:把帳本裡「真的送上去過」的檔案排進待辦,然後清空 entries。 + // + // 🔴 只排 IngestedHash 非空的:沒成功送上去過的檔案,雲端根本沒有它——為它送一次 + // 撤除是零命中的空打。一個兩萬檔的資料夾裡若只有一百檔真的上去過,差別是 200 倍的 + // 雲端呼叫(而且會跟正常同步搶同一個節流器)。 + // + // 清空 entries 讓這一步天然冪等:下一輪回來時 entries 已空、只剩沒送成功的待辦, + // 不會把已經撤掉的又排一次。 + if len(m.Entries) > 0 && !dryRun { + for path, e := range m.Entries { + if e != nil && strings.TrimSpace(e.IngestedHash) != "" { + m.QueueTakedown(path, pageNameOf(path)) + } + } + m.Entries = map[string]*ManifestEntry{} + if serr := m.Save(absManifest); serr != nil { + return []DirectResult{{Type: "folder_takedown", Path: root, Status: "failed", + Error: "撤除待辦存檔失敗(下輪重試):" + serr.Error()}}, 1, len(m.PendingTakedowns), false + } + } else if dryRun { + for path, e := range m.Entries { + if e != nil && strings.TrimSpace(e.IngestedHash) != "" { + m.QueueTakedown(path, pageNameOf(path)) + } + } + } + + saveManifest := func() { + if dryRun { + return + } + if serr := m.Save(absManifest); serr != nil { + results = append(results, DirectResult{Type: "folder_takedown", Path: root, + Status: "failed", Error: "撤除待辦存檔失敗:" + serr.Error()}) + } + } + dr, de := drainPendingTakedowns(cfg, m, absRoot, "folder_takedown", + "移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest) + results = append(results, dr...) + exit = de + + remaining = len(m.PendingTakedowns) + if remaining == 0 && !dryRun { + // 全部撤乾淨 ⇒ 帳本沒有存在的理由了。刪不掉不算失敗(下輪再刪;帳本已空, + // 就算留著也只是個空檔,不會讓資料復活)。 + _ = os.Remove(absManifest) + done = true + } + return results, exit, remaining, done +} + // runDirectOnceRoot 對單一根掃一輪、直送 added/modified/renamed、下架 removed,2xx 後回寫該根 manifest。 // retrySkipReason 產生「為什麼這輪跳過」的人話(t195)。 // 靜默跳過會讓使用者以為檔案被忽略了——狀態要說得出理由(t195 燈號誠實原則同源)。 @@ -1340,44 +1566,11 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta // InkStoneCo#44 ⑩:補打「改名/搬移後還沒下架成功」的舊路徑——包含本輪剛 // 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。 // 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。 - if len(m.PendingTakedowns) == 0 { - // no-op:沒有待辦 - } else if dryRun { - for oldPath := range m.PendingTakedowns { - results = append(results, DirectResult{Type: "renamed_takedown", Path: oldPath, Status: "planned"}) - } - } else { - for oldPath, pageName := range m.PendingTakedowns { - pace() - res := DirectResult{Type: "renamed_takedown", Path: oldPath} - status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{ - "page_name": pageName, - "path": oldPath, - }) - res.HTTPStatus = status - if perr != nil { - res.Status, res.Error = "failed", "改名/搬移後舊頁下架失敗(下輪重試):"+perr.Error() - exit = 1 - } else { - res.Status = "removed" - m.ClearTakedown(oldPath) - // 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理,見下方。 - if cfg.Extractor != "" { - cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName))) - if _, serr := os.Stat(cardAbs); serr == nil { - _ = os.Remove(cardAbs) - } - if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil { - results = append(results, DirectResult{ - Type: "warning", Path: oldPath, Status: "skipped", - Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(), - }) - } - } - } - saveManifest() - results = append(results, res) - } + dr, de := drainPendingTakedowns(cfg, m, absRoot, "renamed_takedown", + "改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest) + results = append(results, dr...) + if de != 0 { + exit = de } // 防呆警告輪:Scan 已壓下 removed 事件,這裡只回報警告不下架。 diff --git a/direct_retire_test.go b/direct_retire_test.go new file mode 100644 index 0000000..5ca1974 --- /dev/null +++ b/direct_retire_test.go @@ -0,0 +1,404 @@ +// direct_retire_test.go — arcrun-rag#46:「把整個資料夾從清單移除」要真的把雲端資料收回。 +// +// 病(leo 2026-08-16 實撞):「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**。」 +// 真兇是 App 的 RemoveFolder 只把路徑從看守清單拿掉,一次都沒碰撤除—— +// 撤除機制本身是好的(有測試、有部署),只是這條路從來不呼叫它。 +// +// 本檔釘的是 collector 這一半:資料夾進了 retiring_folders 之後, +// ① 真的逐筆撤除、② 撤乾淨才算完成、③ **不會波及別的資料夾**(巢狀/同名的邊界)。 +package collector + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +// takedownRecorder 收下每一次撤除呼叫的 payload,讓測試能斷言「殺的是哪一份」。 +type takedownRecorder struct { + mu sync.Mutex + hits []map[string]any + fail bool // true=一律回 500,用來驗「失敗要看得出來、而且下輪還會再試」 +} + +func (rec *takedownRecorder) server() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(body, &m) + rec.mu.Lock() + rec.hits = append(rec.hits, m) + rec.mu.Unlock() + if rec.fail { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) +} + +func (rec *takedownRecorder) paths() []string { + rec.mu.Lock() + defer rec.mu.Unlock() + var out []string + for _, h := range rec.hits { + if p, ok := h["path"].(string); ok { + out = append(out, p) + } + } + return out +} + +// seedSyncedRoot 造一個「已經同步過」的資料夾:實體檔案+標成 ingested 的帳本。 +func seedSyncedRoot(t *testing.T, cfg *DirectConfig, root string, files ...string) string { + t.Helper() + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + absRoot, _ := filepath.Abs(root) + mp := cfg.manifestPathFor(absRoot) + m := &Manifest{FolderID: "fid-" + filepath.Base(root), Root: absRoot, Entries: map[string]*ManifestEntry{}} + for _, f := range files { + if err := os.WriteFile(filepath.Join(root, f), []byte("# "+f), 0o644); err != nil { + t.Fatal(err) + } + m.Entries[f] = &ManifestEntry{ContentHash: "h-" + f, IngestedHash: "h-" + f, IngestedAt: 1} + } + if err := m.Save(mp); err != nil { + t.Fatal(err) + } + return mp +} + +// ① 主線:資料夾進了 retiring_folders ⇒ 每個已上傳的檔都被撤除,帳本收乾淨。 +func TestRetiringFolderTakesDownEveryIngestedFile(t *testing.T) { + rec := &takedownRecorder{} + srv := rec.server() + defer srv.Close() + + base := t.TempDir() + root := filepath.Join(base, "logseq-plugin") + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1", + RetiringFolders: []string{root}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, // 隔離變因:走舊制直送,不打雲端萃取端點 + } + mp := seedSyncedRoot(t, cfg, root, "a.md", "b.md", "notes.md") + + results, exit, remaining, done := retireRootOnce(cfg, root, false) + if exit != 0 { + t.Fatalf("撤除應成功,exit=%d results=%+v", exit, results) + } + if !done || remaining != 0 { + t.Fatalf("三筆都撤成功就該收乾淨,done=%v remaining=%d", done, remaining) + } + got := rec.paths() + if len(got) != 3 { + t.Fatalf("三個已上傳的檔應各撤一次,實得 %d 筆:%v", len(got), got) + } + want := map[string]bool{"a.md": true, "b.md": true, "notes.md": true} + for _, p := range got { + if !want[p] { + t.Errorf("撤除了不該撤的 %q", p) + } + } + // 收乾淨了 ⇒ 帳本沒有存在的理由,且下一輪不會再重排一次 + if _, err := os.Stat(mp); !os.IsNotExist(err) { + t.Errorf("收乾淨後帳本該被刪掉,err=%v", err) + } + before := len(rec.paths()) + if _, _, _, done2 := retireRootOnce(cfg, root, false); !done2 { + t.Error("第二輪應直接回報已完成") + } + if len(rec.paths()) != before { + t.Errorf("第二輪不該再打任何撤除(冪等),before=%d after=%d", before, len(rec.paths())) + } +} + +// ②(最重要)邊界:兩個資料夾放著同名同相對路徑的檔,移除其中一個 +// +// **不可以**碰到另一個。程式這一半的保證=撤除只針對那個根的帳本、 +// 且 payload 帶著逐根導出的 library 讓雲端分得開。 +func TestRetiringOneFolderDoesNotTouchTheOther(t *testing.T) { + rec := &takedownRecorder{} + srv := rec.server() + defer srv.Close() + + base := t.TempDir() + gone := filepath.Join(base, "gone") // 要移除的 + keep := filepath.Join(base, "keep") // 還在用的 + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1", + WatchFolders: []string{keep}, + RetiringFolders: []string{gone}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, + } + // 兩邊都有 notes.md:page_name 與相對 path 完全相同——這正是會誤傷的組合。 + goneMp := seedSyncedRoot(t, cfg, gone, "notes.md", "only-in-gone.md") + keepMp := seedSyncedRoot(t, cfg, keep, "notes.md", "only-in-keep.md") + + if _, exit, _, done := retireRootOnce(cfg, gone, false); exit != 0 || !done { + t.Fatalf("撤除應成功且收乾淨,exit=%d done=%v", exit, done) + } + + // (a) 還在用的那個資料夾的帳本必須毫髮無傷 + keepM, err := LoadManifest(keepMp, keep) + if err != nil { + t.Fatalf("讀不回還在用的帳本:%v", err) + } + if len(keepM.Entries) != 2 { + t.Errorf("還在用的資料夾帳本被動到了:%+v", keepM.Entries) + } + if len(keepM.PendingTakedowns) != 0 { + t.Errorf("還在用的資料夾不該有待撤除:%+v", keepM.PendingTakedowns) + } + if _, err := os.Stat(keepMp); err != nil { + t.Errorf("還在用的資料夾帳本不該被刪:%v", err) + } + if _, err := os.Stat(filepath.Join(keep, "notes.md")); err != nil { + t.Errorf("還在用的資料夾的檔案不該被刪:%v", err) + } + if _, err := os.Stat(goneMp); !os.IsNotExist(err) { + t.Errorf("被移除的資料夾帳本該收掉,err=%v", err) + } + + // (b) 送上雲的每一筆都要帶 library,且是**被移除那個資料夾**的 library—— + // 沒有這一維,雲端無從分辨兩個 notes.md(page_name 與 path 全同)。 + goneLib := cfg.libraryFor(mustAbs(t, gone)) + keepLib := cfg.libraryFor(mustAbs(t, keep)) + if goneLib == keepLib { + t.Fatalf("測試前提壞了:兩個資料夾應導出不同 library,都是 %q", goneLib) + } + rec.mu.Lock() + hits := append([]map[string]any(nil), rec.hits...) + rec.mu.Unlock() + if len(hits) != 2 { + t.Fatalf("只該撤被移除那個資料夾的兩個檔,實得 %d:%v", len(hits), rec.paths()) + } + for _, h := range hits { + if h["library"] != goneLib { + t.Errorf("撤除 payload 的 library=%v,應為被移除資料夾的 %q(否則雲端會誤殺同名檔)", + h["library"], goneLib) + } + } +} + +// ③ 中途失敗要看得出來,而且不會被當成「已收乾淨」默默丟掉。 +func TestRetiringFailureStaysPendingAndVisible(t *testing.T) { + rec := &takedownRecorder{fail: true} + srv := rec.server() + defer srv.Close() + + base := t.TempDir() + root := filepath.Join(base, "flaky") + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1", + RetiringFolders: []string{root}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, + } + mp := seedSyncedRoot(t, cfg, root, "x.md", "y.md") + + results, exit, remaining, done := retireRootOnce(cfg, root, false) + if exit == 0 { + t.Error("雲端回 500 時 exit 不該是 0——失敗要浮上來") + } + if done { + t.Error("沒撤成功就宣告收乾淨=把資料留在雲端卻跟使用者說刪了") + } + if remaining != 2 { + t.Errorf("兩筆都失敗,remaining 應為 2,實得 %d", remaining) + } + var sawFail bool + for _, r := range results { + if r.Status == "failed" && r.Error != "" { + sawFail = true + } + } + if !sawFail { + t.Errorf("失敗要帶原因(畫面要說得出來),results=%+v", results) + } + if _, err := os.Stat(mp); err != nil { + t.Errorf("還沒撤乾淨,帳本不可以刪掉(刪了=待辦永久遺失):%v", err) + } + m, err := LoadManifest(mp, root) + if err != nil { + t.Fatal(err) + } + if len(m.PendingTakedowns) != 2 { + t.Errorf("失敗的兩筆要留在待辦清單等下輪重試,實得 %+v", m.PendingTakedowns) + } + + // 雲端恢復 ⇒ 下一輪自己補完,不需要使用者再按一次 + rec.mu.Lock() + rec.fail = false + rec.mu.Unlock() + if _, exit2, remaining2, done2 := retireRootOnce(cfg, root, false); exit2 != 0 || !done2 || remaining2 != 0 { + t.Errorf("雲端恢復後應自動補完,exit=%d remaining=%d done=%v", exit2, remaining2, done2) + } +} + +// ④ 沒成功上傳過的檔不必空打——雲端根本沒有它。 +func TestRetiringSkipsNeverIngestedFiles(t *testing.T) { + rec := &takedownRecorder{} + srv := rec.server() + defer srv.Close() + + base := t.TempDir() + root := filepath.Join(base, "half") + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1", + RetiringFolders: []string{root}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, + } + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + absRoot, _ := filepath.Abs(root) + m := &Manifest{FolderID: "fid", Root: absRoot, Entries: map[string]*ManifestEntry{ + "up.md": {ContentHash: "h1", IngestedHash: "h1"}, // 上去過 + "stuck.md": {ContentHash: "h2"}, // 從沒成功上去(失敗退避中) + "never.md": {ContentHash: "h3"}, // 同上 + }} + if err := m.Save(cfg.manifestPathFor(absRoot)); err != nil { + t.Fatal(err) + } + + if _, exit, _, done := retireRootOnce(cfg, root, false); exit != 0 || !done { + t.Fatalf("exit=%d done=%v", exit, done) + } + got := rec.paths() + if len(got) != 1 || got[0] != "up.md" { + t.Errorf("只該撤真的上去過的那一筆,實得 %v", got) + } +} + +// ⑤ RetiringRoots() 兩層都要收——t149 的病(只讀了其中一層 ⇒ 新制設定被靜默忽略)。 +func TestRetiringRootsReadsBothLayers(t *testing.T) { + c := &DirectConfig{ + RetiringFolders: []string{"/tmp/legacy"}, + Accounts: []AccountConfig{ + {CypherURL: "https://a.example", RetiringFolders: []string{"/tmp/acc-a"}}, + {CypherURL: "https://b.example", RetiringFolders: []string{"/tmp/acc-b", "/tmp/legacy"}}, + }, + } + got := c.RetiringRoots() + want := []string{"/tmp/legacy", "/tmp/acc-a", "/tmp/acc-b"} + if len(got) != len(want) { + t.Fatalf("RetiringRoots()=%v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("RetiringRoots()[%d]=%s want %s", i, got[i], want[i]) + } + } + // 逐帳號隔離:撤除要打對的實例,不能沿用頂層清單 + sub := c.makeAccountSubConfig(c.Accounts[0]) + if got := sub.RetiringRoots(); len(got) != 1 || got[0] != "/tmp/acc-a" { + t.Fatalf("帳號子設定的 RetiringRoots()=%v,應只有該帳號自己的", got) + } +} + +// ⑥ 端到端接線:RunDirectOnce 真的會處理 retiring_folders,並把進度寫進 status.json +// +// (畫面靠它顯示「正在收回…」與失敗原因;沒接上就等於整條路沒接)。 +func TestRunDirectOnceDrivesRetirementAndReportsStatus(t *testing.T) { + rec := &takedownRecorder{} + srv := rec.server() + defer srv.Close() + + origVer := fetchCloudVersion + fetchCloudVersion = func(string) (string, bool) { return "stub", true } + defer func() { fetchCloudVersion = origVer }() + + base := t.TempDir() + root := filepath.Join(base, "retired") + cfg := &DirectConfig{ + Manifest: filepath.Join(base, "manifest.json"), + Accounts: []AccountConfig{{ + CypherURL: srv.URL, Namespace: "ns1", APIKey: "ns1", + RetiringFolders: []string{root}, + }}, + MaxRemoved: DefaultMaxRemovedRatio, + ExtractorExplicit: true, + } + // 帳本路徑含**帳號**尾碼(t86b),所以要用帳號子設定去算,不能用頂層 cfg + // (頂層沒有 cypher_url ⇒ 算出來是另一個檔,等於什麼都沒 seed)。 + seedSyncedRoot(t, cfg.makeAccountSubConfig(cfg.Accounts[0]), root, "one.md") + + results, exit, _ := RunDirectOnce(cfg, false) + if exit != 0 { + t.Fatalf("exit=%d results=%+v", exit, results) + } + if got := rec.paths(); len(got) != 1 || got[0] != "one.md" { + t.Fatalf("RunDirectOnce 應驅動撤除,實得 %v", got) + } + var tagged bool + for _, r := range results { + if r.Type == "folder_takedown" && r.Root == root && r.Status == "removed" { + tagged = true + } + } + if !tagged { + t.Errorf("撤除結果應標明是哪個資料夾,results=%+v", results) + } + + st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest)) + if err != nil { + t.Fatalf("讀不回 status.json:%v", err) + } + rs, ok := st.Retiring[root] + if !ok { + t.Fatalf("status.json 應回報這個資料夾的收回進度,實得 %+v", st.Retiring) + } + if !rs.Done || rs.Remaining != 0 { + t.Errorf("撤乾淨後應回報 done(App 靠它把設定裡那一筆清掉),實得 %+v", rs) + } +} + +func mustAbs(t *testing.T, p string) string { + t.Helper() + a, err := filepath.Abs(p) + if err != nil { + t.Fatal(err) + } + return a +} + +// ⑦ 舊制(頂層 retiring_folders、沒有 accounts[])也要撤得掉。 +// +// LoadDirectConfig 會把舊制包成 Accounts[0],而 makeAccountSubConfig 用帳號層 +// 覆蓋頂層 ⇒ 遷移時漏帶這一欄,撤除就永遠不會發生、且**沒有任何錯誤訊息**(t149 形狀)。 +func TestLegacyTopLevelRetiringFoldersMigrated(t *testing.T) { + dir := t.TempDir() + p := writeDirectConfig(t, dir, map[string]any{ + "watch_folder": "/tmp/kb", "retiring_folders": []string{"/tmp/gone"}, + "manifest": filepath.Join(dir, "m.json"), + "cypher_url": "https://x.example", "namespace": "demo", + }) + c, err := LoadDirectConfig(p) + if err != nil { + t.Fatal(err) + } + if len(c.Accounts) != 1 { + t.Fatalf("舊制應被包成一個帳號,實得 %d", len(c.Accounts)) + } + sub := c.makeAccountSubConfig(c.Accounts[0]) + if got := sub.RetiringRoots(); len(got) != 1 || got[0] != "/tmp/gone" { + t.Fatalf("舊制的待撤清單沒被帶進帳號層 ⇒ 永遠撤不掉,實得 %v", got) + } +} diff --git a/sync_status.go b/sync_status.go index c7acf9e..09f2463 100644 --- a/sync_status.go +++ b/sync_status.go @@ -40,6 +40,18 @@ type AccountSyncStatus struct { QuotaMessage *QuotaNotice `json:"quota_message,omitempty"` } +// RetiringStatus=某個「已移除、雲端撤除進行中」資料夾的現況(arcrun-rag#46)。 +// +// 為什麼要有這個欄位:撤除可能跨好幾輪(節流+單輪上限+雲端可能剛好掛掉), +// 使用者按下「移除並收回」之後如果畫面什麼都不說,他會以為又是一顆沒作用的按鈕 +// ——那正是這張票的病。⇒ 進度與**失敗的真因**都要看得見 +// (leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤」)。 +type RetiringStatus struct { + Remaining int `json:"remaining"` // 還有幾筆沒撤成功 + Done bool `json:"done"` // 已經收乾淨(App 看到才把設定裡那一筆清掉) + LastError string `json:"last_error,omitempty"` // 最後一次失敗的真因(原文,不改寫) +} + // SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。 // 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。 type SyncStatus struct { @@ -67,6 +79,10 @@ type SyncStatus struct { // t104:per-account 狀態(key = instanceHostOf(cypher_url)) AccountDetails map[string]AccountSyncStatus `json:"account_details,omitempty"` + // arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。 + // 與 SkippedDocs 同族——每輪照現況重算的快照,不進 CarryForwardActivity。 + Retiring map[string]RetiringStatus `json:"retiring,omitempty"` + // 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案, // 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。 // 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。