895d672177
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>
152 lines
5.5 KiB
Go
152 lines
5.5 KiB
Go
// 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)
|
||
}
|
||
}
|