Files
arcrun-collector/cmd/arcrun-app/remove_folder_takedown_test.go
T
Leo 895d672177 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>
2026-08-16 21:35:22 +08:00

152 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}