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:
+110
-3
@@ -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 {
|
||||
|
||||
@@ -268,7 +268,14 @@ function pageLib(s, idx) {
|
||||
<button class="primary" data-addto="${idx}">加入資料夾</button>
|
||||
</div>
|
||||
<div class="kbver">${kbVersionLine(a)}</div>
|
||||
${(a.folders || []).map((f) => `
|
||||
${(a.folders || []).map((f) => f.retiring ? `
|
||||
<div class="folder">
|
||||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
|
||||
<span class="tag retiring">${f.retireError
|
||||
? '收回時出錯,會自動再試'
|
||||
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`}</span>
|
||||
</div>
|
||||
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
|
||||
<div class="folder">
|
||||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
|
||||
<span class="tag">自動同步中</span>
|
||||
@@ -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(`
|
||||
<h2>移除這個資料夾?</h2>
|
||||
<p>「${esc(path)}」不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
|
||||
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
|
||||
<p>「${esc(path)}」要怎麼處理?<b>你電腦裡的原始檔案不會被動到</b>,兩個選擇的差別只在雲端。</p>
|
||||
<label class="radio"><input type="radio" name="rmMode" value="takedown" checked/>
|
||||
<span><b>連同雲端的知識一起收回</b><br/>
|
||||
<span class="d">這個資料夾整理出來的知識會從知識庫刪除,之後搜尋找不到、AI 也不會再拿它回答。<b>刪掉就要不回來</b>。</span></span></label>
|
||||
<label class="radio"><input type="radio" name="rmMode" value="unwatch"/>
|
||||
<span><b>只停止同步,雲端的知識保留</b><br/>
|
||||
<span class="d">以後這個資料夾有變動不會再上傳,但之前整理好的知識留在知識庫裡,搜尋和 AI 照樣找得到。</span></span></label>
|
||||
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">確定</button></div>`,
|
||||
() => {
|
||||
$('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();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 事件,這裡只回報警告不下架。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 進資料夾,得到的回應是**完全的沉默**。
|
||||
|
||||
Reference in New Issue
Block a user