fix(daemon): 移除資料夾要真的把雲端資料收回(arcrun-rag#46)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:35:22 +08:00
parent 345be49d4d
commit 895d672177
7 changed files with 941 additions and 45 deletions
@@ -0,0 +1,151 @@
// remove_folder_takedown_test.go — arcrun-rag#46:按下「移除」之後,資料真的要被收回。
//
// leo 2026-08-16 實撞:「我去把 Logseq plugin 刪掉以後,**採集的 wiki 沒消失**。」
// 真兇:RemoveFolder 只把路徑從 WatchFolders 拿掉、存檔、重啟看守——**一次都沒碰撤除**。
//
// 本檔釘 App 這一半的三件事:
// 1. 選了「連同雲端一起收回」⇒ 資料夾要進 retiring_folderscollector 靠它才知道要撤)
// 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 500boom"},
// "/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)
}
}