diff --git a/CHANGELOG.md b/CHANGELOG.md index d749521..2d48c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,13 @@ --- -## 下一版(未發佈) +## 0.18.37(2026-08-26) + +- 🔴 **雲端被清空過的檔案,小幫手現在會自己發現並補送**:以前小幫手送成功一次就會在本機蓋一個「已送」的章,而那個章**永遠不會過期**。萬一雲端被重裝或清空,那批檔案就**再也不會被送上去**,而且沒有任何地方會跟你說——你只會發現「檔案明明在資料夾裡,AI 卻查不到」,而且查不出為什麼。現在它會跟雲端對一次帳,發現那邊沒有就重新送。 +- **重跑第二次不會又全部送一遍**:對完帳之後它分得出「雲端真的沒有」與「我上次送過了」,不會把你的 AI 額度燒掉。 +- **補送的時候看得到**:不會靜悄悄跑完。 + +## 0.18.36(2026-08-24) - 🔴 **移除資料夾時,可以順便把我們放進去的隱藏檔案一起清掉**:小幫手整理你的資料夾時,會在**每一層**放一個隱藏的整理稿目錄(`.wiki`/`.arcrun-rag`)。以前你把資料夾從清單移除之後,這些東西**全部留在原地**——隱藏的、散在幾十層裡,你自己幾乎刪不乾淨。現在移除的對話框多了一個勾選框「順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉」,勾起來會**先把確切要刪哪些列給你看**,你看過再按確定。 - **只刪我們自己建的,你的檔案一個都不會碰**:每一筆都要有我們自己寫下的依據(我們放的說明檔、我們的整理帳本、我們的檔名前綴)才會刪;只要有一個東西認不出來,那整個目錄就原封不動留著,並且在清單上告訴你留了什麼、為什麼留。 diff --git a/cloud_audit.go b/cloud_audit.go new file mode 100644 index 0000000..1ed1ae8 --- /dev/null +++ b/cloud_audit.go @@ -0,0 +1,284 @@ +// cloud_audit.go — 本機帳本 ↔ 雲端索引對帳(`inkstone/arcrun-rag#140`)。 +// +// 病(`inkstone/Arcrun#165` 斷點一,2026-08-26 實測): +// +// 本機 manifest 蓋的「已送成功」章**永遠不會過期**。雲端在 2026-08-14 被重裝/清空之後, +// 那些檔案的 content_hash 沒變 ⇒ Scan() 不產生事件 ⇒ **永遠不會重送**, +// 而且**沒有任何地方會說話**。使用者看到的是「檔案明明在資料夾裡,AI 卻查不到」。 +// 實據:youlinhsieh-test1 的 8 個檔 ingested_at 落在 07-29~08-06、零 fail_count, +// 雲端 D1 的 MIN(created_at) 卻是 08-14 20:27 ⇒ 分界線乾淨得可怕。 +// +// 解的形狀(**不是把兩邊砍掉重來**,那是紅線): +// +// 章不再是「我送過了」,而是「我送過了**而且雲端現在還有**」。 +// 後半句本機答不出來,只能去問雲端 ⇒ 這支檔就是那個問句。 +// +// 為什麼問得起(不必等雲端先改):cypher-executor 早就有 `/kbdb/entries` proxy, +// 認證用的正是 daemon 本來就帶著的 `X-Arcrun-API-Key`(routes/kbdb-proxy.ts, +// owner_id 由 server 端強制注入,租戶跨不過去)。⇒ **零雲端改動、零新憑證。** +// +// 三條自我約束(對應票上的紅線): +// - **不刪任何東西**:對帳只會把本機的章拔掉(讓它重走既有的送件路), +// 不碰雲端 entry、不碰 .wiki 卡、不碰使用者的檔。 +// - **不做全量重送**:一次只問 cloudAuditBatch 個檔,且同一個檔 +// cloudAuditRecheckInterval 內只問一次;問到「雲端沒有」才拔章。 +// - **不會重送兩次**:拔過章的檔在 cloudAuditRepairGrace 內不再被拔第二次 +// (雲端 ingest 是非同步的,剛送出去那幾秒查不到是正常的—— +// 沒有這道閘,它會變成一個每輪重送、把額度燒光的迴圈, +// 那就是「把一個 bug 換成另一個」)。 +package collector + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// cloudAuditRecheckInterval=同一個檔多久重新對一次帳。 +// 24 小時的理由:這是「雲端被清空」這種罕見事故的偵測延遲上限, +// 而使用者按「立刻同步」(ForceSync)時本輪一律重對,不必等它。 +const cloudAuditRecheckInterval = 24 * time.Hour + +// cloudAuditRepairGrace=同一個檔拔過章之後,多久內不准再拔第二次。 +// +// 🔴 這是「重跑第二次不可以又全部重送一遍」(#140 驗收條件 5)的那道閘。 +// 雲端 rag_ingest_card 是觸發式的,卡片不會在 POST 回來的那一瞬間就查得到; +// 沒有這個窗口,對帳會在下一輪又判它「不在」⇒ 重送 ⇒ 無限迴圈 ⇒ 燒光額度。 +const cloudAuditRepairGrace = 24 * time.Hour + +// cloudAuditBatch=單輪最多問幾個檔。 +// 與 DefaultMaxEventsPerRun 同一個精神:巨量積壓(實據 27,164 檔)不該一輪湧完。 +const cloudAuditBatch = 20 + +// cloudAuditFolderInterval=同一個資料夾兩次對帳批次的最小間隔。 +// daemon 預設 5 秒一輪,沒有這道節流就是每 5 秒 20 個請求。 +const cloudAuditFolderInterval = 60 * time.Second + +// cloudAuditProbePace=批次內每個請求之間的間隔。 +// 比 directPaceInterval(700ms)短很多是因為這是 D1 的單筆 COUNT,不是 LLM—— +// 20 個請求約 2 秒跑完,不會讓一輪掃描明顯變慢。 +const cloudAuditProbePace = 100 * time.Millisecond + +// cloudAuditHTTP:對帳是唯讀查詢,逾時要短——問不到就當「這輪沒查」, +// 絕不能因為雲端慢就把整輪同步拖住(同 cloud_version.go 的判準)。 +var cloudAuditHTTP = &http.Client{Timeout: 20 * time.Second} + +// probeCloudCard 可在測試中替換,避免真實網路呼叫。 +var probeCloudCard = cloudCardPresent + +// cloudCardPresent 問雲端「這個庫裡還有沒有這個檔的卡」。 +// +// 判準=`metadata_json.source == kb://<相對路徑>#0`。為什麼是 `#0` 而不是頁名: +// - 頁名是 basename 去副檔名(pageNameOf),**兩個資料夾裡的同名檔會撞在一起**; +// - `source` 是 rag_ingest_card parse_card 寫死的 `kb://#<第幾塊>`, +// 而第 0 塊必然存在(卡片至少一塊)⇒ 它就是「這張卡在不在」的存在性鍵, +// 也正是 rag_takedown_direct 用來比對的同一個鍵(兩邊用同一把尺)。 +// +// `offset` 故意帶一個大數:回應只要 `total`,不要 entries 本身—— +// 卡片內文動輒數 KB,20 個檔就是幾百 KB 的白搭流量。 +// +// 回傳的 ok=false=**這次沒查成**(連不上、非 2xx、回應不是 JSON)。 +// 呼叫端必須把它當「不知道」,**不准當成「雲端沒有」**—— +// 那會讓一次網路抖動變成一次全量重送。 +func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool, ok bool, err error) { + base := strings.TrimSuffix(cfg.CypherURL, "/") + if base == "" || strings.TrimSpace(cfg.APIKey) == "" { + return false, false, fmt.Errorf("沒有雲端連線資訊(cypher_url/api_key 是空的)") + } + q := url.Values{} + q.Set("source", "kb://"+relPath+"#0") + q.Set("entry_type", "block") + q.Set("limit", "1") + q.Set("offset", "1000000") // 只要 total,不要把卡片內文整包拉下來 + if strings.TrimSpace(library) != "" { + q.Set("library", library) + } + req, err := http.NewRequest(http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil) + if err != nil { + return false, false, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Arcrun-API-Key", cfg.APIKey) + resp, err := cloudAuditHTTP.Do(req) + if err != nil { + return false, false, err + } + defer resp.Body.Close() + body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false, false, fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if rerr != nil { + return false, false, rerr + } + var payload struct { + Success *bool `json:"success"` + Total *int `json:"total"` + } + if jerr := json.Unmarshal(body, &payload); jerr != nil { + return false, false, fmt.Errorf("雲端回應不是 JSON:%s", strings.TrimSpace(string(body))) + } + // 🔴 total 缺席時回 ok=false 而不是「total=0」。少一個欄位可能是端點換了形狀, + // 把它讀成「雲端沒有這個檔」就會全部重送——寧可什麼都不做。 + if payload.Total == nil || (payload.Success != nil && !*payload.Success) { + return false, false, fmt.Errorf("雲端回應少了 total 欄位:%s", strings.TrimSpace(string(body))) + } + return *payload.Total > 0, true, nil +} + +// auditCandidates 挑出這一輪要問的檔:蓋過章、且太久沒對過帳的,最久沒對的排前面。 +// +// 三種**不問**(每一種都對應一個會咬人的情境): +// - 沒蓋過章(IngestedHash 空):雲端本來就不該有它,Scan() 自己會重送。 +// - NoCloudCard:萃取判定「無可萃取概念」⇒ 本來就沒送過卡上雲。 +// 不排除它的話,對帳每次都會說「雲端沒有」⇒ 每天重萃一次,永遠停不下來。 +// - 剛拔過章還在 grace 內:見 cloudAuditRepairGrace。 +func auditCandidates(m *Manifest, now int64, force bool) []string { + recheck := int64(cloudAuditRecheckInterval.Seconds()) + grace := int64(cloudAuditRepairGrace.Seconds()) + var out []string + for p, e := range m.Entries { + if e == nil || strings.TrimSpace(e.IngestedHash) == "" || e.NoCloudCard { + continue + } + if e.CloudMissingAt > 0 && now-e.CloudMissingAt < grace { + continue + } + if !force && e.CloudCheckedAt > 0 && now-e.CloudCheckedAt < recheck { + continue + } + out = append(out, p) + } + // 最久沒對帳的先問(0=從沒問過,天然排最前);同分時按路徑排序,讓同一份輸入 + // 永遠得到同一個輸出(可測)。 + sort.Slice(out, func(i, j int) bool { + ai, aj := m.Entries[out[i]].CloudCheckedAt, m.Entries[out[j]].CloudCheckedAt + if ai != aj { + return ai < aj + } + return out[i] < out[j] + }) + if len(out) > cloudAuditBatch { + out = out[:cloudAuditBatch] + } + return out +} + +// auditResult=一輪對帳的結果,給呼叫端組人話與記帳用。 +type auditResult struct { + Checked int // 這輪真的問了幾個檔 + Voided int // 其中幾個雲端查不到、章被拔掉(=已排進重送佇列) + Err string // 對帳本身失敗的真因(原文,不改寫——leo 2026-08-06 原則) +} + +// auditCloudLedger 對一個監看根跑一輪對帳。回 nil=這輪不必對(節流中/沒有候選)。 +// +// 🔴 它**只做一件事**:把「雲端已經沒有了」的章拔掉。 +// 拔完之後,接下來的 Scan() 會因為 `orig[p].IngestedHash == ""` 自然補一發 added +// (scan.go 步驟 4「上輪偵測過但 ingest 未成功 → 重試」), +// 於是重送走的是**既有的**萃取路:既有的單輪上限、既有的失敗退避、既有的額度冷卻 +// 全部照舊生效。⇒ 沒有第二條送件路,也沒有第二套節流要維護。 +func auditCloudLedger(cfg *DirectConfig, absRoot string, m *Manifest, dryRun bool, now time.Time) *auditResult { + if dryRun { + return nil + } + nowUnix := now.Unix() + if !cfg.ForceSync && m.CloudAuditAt > 0 && + nowUnix-m.CloudAuditAt < int64(cloudAuditFolderInterval.Seconds()) { + return nil // 節流:同一個資料夾一分鐘內不重問 + } + cands := auditCandidates(m, nowUnix, cfg.ForceSync) + if len(cands) == 0 { + // 沒有候選也要記時間,否則每輪都要重走一次上面那個迴圈。 + m.CloudAuditAt = nowUnix + return nil + } + lib := cfg.libraryFor(absRoot) + res := &auditResult{} + for _, p := range cands { + e := m.Entries[p] + if e == nil { + continue + } + present, ok, err := probeCloudCard(cfg, lib, p) + if !ok { + // 問不到就整批停手:連不上時繼續問剩下的 19 個只是重複同一個錯誤。 + // **絕不把「沒查成」當成「雲端沒有」**(那會讓一次網路抖動變成全量重送)。 + if err != nil { + res.Err = err.Error() + } + break + } + res.Checked++ + e.CloudCheckedAt = nowUnix + if present { + continue + } + // 雲端真的沒有 ⇒ 這個章是假的,拔掉。**不動使用者的任何東西。** + e.IngestedHash = "" + e.CloudMissingAt = nowUnix + // 失敗退避是「這個檔壞掉」的病歷,跟「雲端被清空」無關——留著會讓補送 + // 卡在上一次的退避階梯裡(實測那 8 個檔 fail_count 全是 0,但別的資料夾不一定)。 + e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0 + res.Voided++ + pace2(cloudAuditProbePace) + } + m.CloudAuditAt = nowUnix + if res.Checked == 0 && res.Err == "" { + return nil + } + return res +} + +// ResyncSummary 從 manifest 現況算出「補送」這件事現在講到哪裡」。 +// +// 為什麼是**從 manifest 重算**而不是記一個計數器:計數器是「本輪做了幾件事」, +// 沒事做的那輪就會歸零——2026-08-05 leo 實撞的「明明做完了畫面卻永遠寫等待中」 +// 就是那個形狀。這裡算的是**現況**(還有幾份沒補回來),補完自然歸零、不必有人去清。 +func ResyncSummary(m *Manifest, now time.Time) (pending, repaired int) { + nowUnix := now.Unix() + fresh := int64(cloudAuditRepairGrace.Seconds()) + for _, e := range m.Entries { + if e == nil || e.CloudMissingAt == 0 { + continue + } + if strings.TrimSpace(e.IngestedHash) == "" { + pending++ // 章拔掉了、還沒補送成功 + } else if e.IngestedAt >= e.CloudMissingAt && nowUnix-e.IngestedAt < fresh { + repaired++ // 剛補送成功(只講最近一天的,不然這行會永遠掛在畫面上) + } + } + return pending, repaired +} + +// resyncNote 把上面兩個數字翻成使用者看得懂的一句話。 +// +// leo 2026-08-06 的原則(「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤」) +// 在這裡的形狀是:**不能靜悄悄地重送**。使用者只要看到「有 8 份在補送」, +// 就知道畫面上的數字為什麼會動、也知道不是自己弄壞的。 +func resyncNote(pending, repaired int, lastErr string) string { + switch { + case pending > 0 && repaired > 0: + return fmt.Sprintf("雲端上找不到先前送過的檔案(知識庫可能重裝過),正在自動補送:已補回 %d 份,還有 %d 份排隊中。", repaired, pending) + case pending > 0: + return fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(知識庫可能重裝過),已排進佇列自動補送,你不必做任何事。", pending) + case repaired > 0: + return fmt.Sprintf("已把雲端上遺失的 %d 份檔案補送回知識庫。", repaired) + case lastErr != "": + return "暫時無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試)。" + } + return "" +} + +// pace2=可指定長度的節流(directPaceInterval 那支是萃取路專用的固定 700ms)。 +// 拉成獨立函式是為了讓測試能把它調成 0。 +var pace2 = func(d time.Duration) { + if d > 0 { + time.Sleep(d) + } +} diff --git a/cloud_audit_test.go b/cloud_audit_test.go new file mode 100644 index 0000000..48f6e6e --- /dev/null +++ b/cloud_audit_test.go @@ -0,0 +1,390 @@ +package collector + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// 每個測試都把節流關掉,避免 20 個探測睡 2 秒。 +func init() { pace2 = func(time.Duration) {} } + +func newAuditManifest(paths ...string) *Manifest { + m := &Manifest{FolderID: "f", Root: "/root", Entries: map[string]*ManifestEntry{}} + for _, p := range paths { + m.Entries[p] = &ManifestEntry{ + ContentHash: "sha256:" + p, + IngestedHash: "sha256:" + p, + IngestedAt: 100, + } + } + return m +} + +// stubProbe 讓測試指定「雲端有哪些」,並記錄被問了幾次。 +func stubProbe(t *testing.T, present map[string]bool, err error) *int { + t.Helper() + calls := 0 + orig := probeCloudCard + probeCloudCard = func(cfg *DirectConfig, library, relPath string) (bool, bool, error) { + calls++ + if err != nil { + return false, false, err + } + return present[relPath], true, nil + } + t.Cleanup(func() { probeCloudCard = orig }) + return &calls +} + +func auditCfg() *DirectConfig { + return &DirectConfig{CypherURL: "https://x.invalid", APIKey: "ns", Namespace: "ns"} +} + +// 本票的主場景:雲端被清空 ⇒ 章全部作廢 ⇒ Scan() 補發 added ⇒ 重送。 +func TestAudit_CloudWiped_VoidsStampsAndScanReemits(t *testing.T) { + m := newAuditManifest("a.md", "b.pdf") + stubProbe(t, map[string]bool{}, nil) // 雲端什麼都沒有 + now := time.Unix(1_000_000, 0) + + res := auditCloudLedger(auditCfg(), "/root", m, false, now) + if res == nil || res.Voided != 2 || res.Checked != 2 { + t.Fatalf("預期對帳 2 份、作廢 2 份,實得 %+v", res) + } + for p, e := range m.Entries { + if e.IngestedHash != "" { + t.Fatalf("%s 的章沒有被拔掉", p) + } + if e.CloudMissingAt != now.Unix() { + t.Fatalf("%s 沒有記下 cloud_missing_at", p) + } + } + + // 這才是真正的驗收:拔章之後 Scan() 要自己補發 added(不必另造送件路)。 + root := t.TempDir() + writeFile(t, root, "a.md", "hello", time.Unix(900_000, 0)) + writeFile(t, root, "b.pdf", "world", time.Unix(900_000, 0)) + m2 := newAuditManifest("a.md", "b.pdf") + for _, e := range m2.Entries { + e.IngestedHash = "" // 對帳剛拔掉的狀態 + } + // content_hash 讓 Scan 自己重算,這裡只要它認得出「這兩個路徑還沒 ingest 成功」。 + payload, err := Scan(root, m2, ScanOptions{MaxRemovedRatio: 0.4}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + added := map[string]bool{} + for _, ev := range payload.Events { + if ev.Type == "added" { + added[ev.Path] = true + } + } + if !added["a.md"] || !added["b.pdf"] { + t.Fatalf("拔章後 Scan 沒有補發 added,實得 %+v", payload.Events) + } +} + +// 驗收條件 5:重跑第二次不可以又全部重送一遍。 +func TestAudit_SecondRunDoesNotVoidAgain(t *testing.T) { + m := newAuditManifest("a.md") + stubProbe(t, map[string]bool{}, nil) + now := time.Unix(1_000_000, 0) + + if r := auditCloudLedger(auditCfg(), "/root", m, false, now); r == nil || r.Voided != 1 { + t.Fatalf("第一輪應該作廢 1 份,實得 %+v", r) + } + // 模擬補送成功(雲端仍在 ingest 中、查不到) + m.MarkIngestedBy("a.md", "sha256:a.md", now.Unix()+1, "workers-ai") + + // 一小時後再對帳:雲端還是查不到,但 grace 內**不准**再拔一次章。 + later := now.Add(time.Hour) + m.CloudAuditAt = 0 // 解除資料夾層節流,只考驗 per-path grace + r2 := auditCloudLedger(auditCfg(), "/root", m, false, later) + if r2 != nil && r2.Voided > 0 { + t.Fatalf("grace 期間又拔了章 ⇒ 會變成無限重送迴圈:%+v", r2) + } + if m.Entries["a.md"].IngestedHash == "" { + t.Fatal("章被重複拔掉了") + } +} + +// 雲端有的檔不准被動到(否則就是全量重送)。 +func TestAudit_PresentCardsUntouched(t *testing.T) { + m := newAuditManifest("a.md", "b.md") + stubProbe(t, map[string]bool{"a.md": true, "b.md": true}, nil) + now := time.Unix(1_000_000, 0) + + res := auditCloudLedger(auditCfg(), "/root", m, false, now) + if res == nil || res.Voided != 0 || res.Checked != 2 { + t.Fatalf("預期 0 作廢、2 已對帳,實得 %+v", res) + } + for p, e := range m.Entries { + if e.IngestedHash == "" { + t.Fatalf("%s 的章被誤拔", p) + } + if e.CloudCheckedAt != now.Unix() { + t.Fatalf("%s 沒記下對帳時間 ⇒ 下一輪會重問,變成請求風暴", p) + } + } +} + +// 🔴 網路抖動不准被讀成「雲端沒有」——那會讓一次逾時變成一次全量重送。 +func TestAudit_ProbeFailureNeverVoids(t *testing.T) { + m := newAuditManifest("a.md", "b.md") + calls := stubProbe(t, nil, fmt.Errorf("connection reset")) + res := auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000, 0)) + if res == nil || res.Voided != 0 || res.Err == "" { + t.Fatalf("查不到時不該作廢、且要留下真因,實得 %+v", res) + } + if *calls != 1 { + t.Fatalf("問不到就該整批停手(只問 1 次),實得 %d 次", *calls) + } + for p, e := range m.Entries { + if e.IngestedHash == "" { + t.Fatalf("%s 因為一次網路錯誤就被作廢", p) + } + } +} + +// 從沒送成功過的檔不必問雲端(Scan 自己會重送),沒送過卡的檔更不能問(會無限重萃)。 +func TestAudit_SkipsUnstampedAndNoCardEntries(t *testing.T) { + m := newAuditManifest("stamped.md", "unstamped.md", "nocard.md") + m.Entries["unstamped.md"].IngestedHash = "" + m.Entries["nocard.md"].NoCloudCard = true + calls := stubProbe(t, map[string]bool{}, nil) + + auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000, 0)) + if *calls != 1 { + t.Fatalf("只該問 stamped.md 一個,實得 %d 次", *calls) + } + if m.Entries["nocard.md"].IngestedHash == "" { + t.Fatal("沒送過卡的檔被作廢 ⇒ 會每天重萃一次,永遠停不下來") + } +} + +// 單輪上限+最久沒對帳的排前面(巨量資料夾也要輪得完)。 +func TestAudit_BatchCapAndOldestFirst(t *testing.T) { + m := &Manifest{FolderID: "f", Root: "/root", Entries: map[string]*ManifestEntry{}} + for i := 0; i < cloudAuditBatch+5; i++ { + p := fmt.Sprintf("f%02d.md", i) + m.Entries[p] = &ManifestEntry{ + ContentHash: "h", IngestedHash: "h", + CloudCheckedAt: int64(i), // 越前面越久沒對 + } + } + var asked []string + orig := probeCloudCard + probeCloudCard = func(cfg *DirectConfig, library, relPath string) (bool, bool, error) { + asked = append(asked, relPath) + return true, true, nil + } + t.Cleanup(func() { probeCloudCard = orig }) + + // force=true 才會忽略「24 小時內對過帳」的閘(上面的 CloudCheckedAt 是很小的數字, + // 對現在的時間來說早就過期了,這裡用 force 讓意圖明確)。 + cfg := auditCfg() + cfg.ForceSync = true + auditCloudLedger(cfg, "/root", m, false, time.Unix(1_000_000, 0)) + if len(asked) != cloudAuditBatch { + t.Fatalf("單輪上限沒生效:問了 %d 個(上限 %d)", len(asked), cloudAuditBatch) + } + if asked[0] != "f00.md" { + t.Fatalf("最久沒對帳的沒排最前面,第一個是 %s", asked[0]) + } +} + +// 資料夾層節流:daemon 每 5 秒一輪,不能每輪都對雲端發一批請求。 +func TestAudit_FolderIntervalThrottle(t *testing.T) { + m := newAuditManifest("a.md") + calls := stubProbe(t, map[string]bool{"a.md": true}, nil) + now := time.Unix(1_000_000, 0) + + auditCloudLedger(auditCfg(), "/root", m, false, now) + auditCloudLedger(auditCfg(), "/root", m, false, now.Add(5*time.Second)) + if *calls != 1 { + t.Fatalf("節流沒生效:5 秒內問了 %d 次", *calls) + } + // 使用者按「立刻同步」要能穿透節流。 + cfg := auditCfg() + cfg.ForceSync = true + auditCloudLedger(cfg, "/root", m, false, now.Add(6*time.Second)) + if *calls != 2 { + t.Fatalf("立刻同步沒有穿透節流:共問了 %d 次", *calls) + } +} + +// 24 小時內對過帳的檔不重問(一般輪次)。 +func TestAudit_RecheckInterval(t *testing.T) { + m := newAuditManifest("a.md") + m.Entries["a.md"].CloudCheckedAt = 1_000_000 + calls := stubProbe(t, map[string]bool{"a.md": true}, nil) + auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000+3600, 0)) + if *calls != 0 { + t.Fatalf("一小時前才對過帳,不該重問(實得 %d 次)", *calls) + } +} + +// dryRun 不准碰帳本。 +func TestAudit_DryRunDoesNothing(t *testing.T) { + m := newAuditManifest("a.md") + calls := stubProbe(t, map[string]bool{}, nil) + if r := auditCloudLedger(auditCfg(), "/root", m, true, time.Unix(1_000_000, 0)); r != nil { + t.Fatalf("dry-run 不該做任何事,實得 %+v", r) + } + if *calls != 0 || m.Entries["a.md"].IngestedHash == "" || m.CloudAuditAt != 0 { + t.Fatal("dry-run 動了帳本或打了網路") + } +} + +// 🔴 scan.go 的 carry 段漏欄位是這個檔案歷史上犯過兩次的錯(t195 fail_count、5fcc139 +// LastError)。第三次的代價是「防重送迴圈的 grace 消失 ⇒ 燒光額度」,所以釘死它。 +func TestScan_CarriesCloudAuditFields(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "hello", time.Unix(900_000, 0)) + m := &Manifest{FolderID: "f", Root: root, Entries: map[string]*ManifestEntry{}} + if _, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.4}); err != nil { + t.Fatalf("首輪 Scan: %v", err) + } + e := m.Entries["a.md"] + e.IngestedHash = e.ContentHash + e.CloudCheckedAt = 111 + e.CloudMissingAt = 222 + e.NoCloudCard = true + + if _, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.4}); err != nil { + t.Fatalf("次輪 Scan: %v", err) + } + got := m.Entries["a.md"] + if got.CloudCheckedAt != 111 || got.CloudMissingAt != 222 || !got.NoCloudCard { + t.Fatalf("Scan 重建 entry 時把雲端對帳欄位抹掉了:%+v", got) + } +} + +// MarkIngestedBy 要保住 grace 標記(不然剛補送成功的檔立刻又回到可拔章的池子), +// 但要把 NoCloudCard 歸位(這一輪送了卡就不是「沒卡的檔」了)。 +func TestMarkIngested_KeepsGraceClearsNoCard(t *testing.T) { + m := newAuditManifest("a.md") + e := m.Entries["a.md"] + e.CloudCheckedAt, e.CloudMissingAt, e.NoCloudCard = 111, 222, true + m.MarkIngestedBy("a.md", "h2", 999, "workers-ai") + if e.CloudCheckedAt != 111 || e.CloudMissingAt != 222 { + t.Fatalf("對帳記錄被清掉了:%+v", e) + } + if e.NoCloudCard { + t.Fatal("NoCloudCard 沒有歸位") + } + if !m.MarkNoCloudCard("a.md") || !e.NoCloudCard { + t.Fatal("MarkNoCloudCard 沒生效") + } +} + +// 補送進度是「現況快照」而不是「本輪計數」——沒事做的那輪不能歸零、補完要自然消失。 +func TestResyncSummary(t *testing.T) { + now := time.Unix(1_000_000, 0) + m := newAuditManifest("pending.md", "repaired.md", "normal.md") + m.Entries["pending.md"].IngestedHash = "" + m.Entries["pending.md"].CloudMissingAt = now.Unix() - 60 + m.Entries["repaired.md"].CloudMissingAt = now.Unix() - 60 + m.Entries["repaired.md"].IngestedAt = now.Unix() - 30 + + pending, repaired := ResyncSummary(m, now) + if pending != 1 || repaired != 1 { + t.Fatalf("預期 pending=1 repaired=1,實得 %d/%d", pending, repaired) + } + if note := resyncNote(pending, repaired, ""); !strings.Contains(note, "補送") { + t.Fatalf("人話沒生出來:%q", note) + } + // 一天後那句「已補回」要自己消失,不能永遠掛在畫面上。 + _, repairedLater := ResyncSummary(m, now.Add(25*time.Hour)) + if repairedLater != 0 { + t.Fatalf("舊的補送紀錄沒有過期:%d", repairedLater) + } +} + +// cloudCardPresent 打的是真的 HTTP,形狀對不對用假伺服器釘住 +// (URL 形狀一旦漂掉,對帳會靜默地永遠回「雲端沒有」=全量重送)。 +func TestCloudCardPresent_RequestShapeAndParsing(t *testing.T) { + var got url.Values + var gotKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Query() + gotKey = r.Header.Get("X-Arcrun-API-Key") + if r.URL.Path != "/kbdb/entries" { + t.Errorf("打錯端點:%s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "entries": []any{}, "count": 0, "total": 1}) + })) + defer srv.Close() + + cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "ns"} + present, ok, err := cloudCardPresent(cfg, "mylib", "docs/小果被AFTEE詐貸.pdf") + if err != nil || !ok || !present { + t.Fatalf("total=1 應該讀成 present,實得 present=%v ok=%v err=%v", present, ok, err) + } + if got.Get("source") != "kb://docs/小果被AFTEE詐貸.pdf#0" { + t.Fatalf("source 鍵不對:%q", got.Get("source")) + } + if got.Get("library") != "mylib" || got.Get("entry_type") != "block" { + t.Fatalf("庫/型別 filter 不對:%v", got) + } + if got.Get("offset") == "" || got.Get("offset") == "0" { + t.Fatalf("沒有用大 offset 避開卡片內文:%q", got.Get("offset")) + } + if gotKey != "ns" { + t.Fatalf("沒帶 X-Arcrun-API-Key:%q", gotKey) + } +} + +func TestCloudCardPresent_ZeroTotalIsMissing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "total": 0}) + })) + defer srv.Close() + present, ok, err := cloudCardPresent(&DirectConfig{CypherURL: srv.URL, APIKey: "ns"}, "lib", "a.md") + if err != nil || !ok || present { + t.Fatalf("total=0 應該讀成 missing,實得 present=%v ok=%v err=%v", present, ok, err) + } +} + +// 🔴 端點換了形狀(少了 total)要回「不知道」,不准回「雲端沒有」。 +func TestCloudCardPresent_MissingTotalIsUnknown(t *testing.T) { + cases := map[string]http.HandlerFunc{ + "沒有 total 欄位": func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "entries": []any{}}) + }, + "success=false": func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": false, "total": 0, "error": "boom"}) + }, + "不是 JSON": func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("cloudflare")) + }, + "401": func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + _, _ = w.Write([]byte(`{"error":"缺少 X-Arcrun-API-Key header"}`)) + }, + } + for name, h := range cases { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(h) + defer srv.Close() + _, ok, err := cloudCardPresent(&DirectConfig{CypherURL: srv.URL, APIKey: "ns"}, "lib", "a.md") + if ok { + t.Fatalf("%s 應該回 ok=false(不知道),不能回「雲端沒有」", name) + } + if err == nil { + t.Fatalf("%s 沒有留下真因", name) + } + }) + } +} + +func TestCloudCardPresent_NoConnectionInfo(t *testing.T) { + if _, ok, _ := cloudCardPresent(&DirectConfig{}, "lib", "a.md"); ok { + t.Fatal("沒有連線資訊時不該宣稱查得到答案") + } +} diff --git a/cmd/arcrun-app/.version-source-files.json b/cmd/arcrun-app/.version-source-files.json index ab37aef..a50822d 100644 --- a/cmd/arcrun-app/.version-source-files.json +++ b/cmd/arcrun-app/.version-source-files.json @@ -1,20 +1,25 @@ { "_algo": 4, - "version": "0.18.35", - "fingerprint": "3bd7f3fb0124bda4", + "version": "0.18.37", + "fingerprint": "3bb5c94f68a26a06", "files": { ".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97", - "CHANGELOG.md": "1c740bc8fa983be721c80c95b7941504d3d5e42c6e8d663a59d9c294d72e5877", + "CHANGELOG.md": "ba7a2a7cc8071e62c7493057abff43c81739c7828332a9124fcb415263b3b649", "DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d", "README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f", "check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853", + "cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc", + "cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0", + "cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0", + "cloud_audit.go": "a51f5c6eafaa75ce282b307e8dd2986f0b316a3c24282bed0856002819dd86e2", + "cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a", "cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f", "cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa", "cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9", "cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6", "cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8", "cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9", - "cmd/arcrun-app/app.go": "3d61ca98be11ff8049808331eee07b6001cc24b44d23440c694b267a0b3cd942", + "cmd/arcrun-app/app.go": "d7552348ea2c662bceabd866a33650c69278420374fc95200c1c86270a6e46b8", "cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96", "cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c", "cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79", @@ -51,6 +56,7 @@ "cmd/arcrun-app/changelog-section.sh": "8b39dfdfbf8670fc850102e43b056b98dda52b2a7bd2a49f8d961c171c7abe23", "cmd/arcrun-app/check-cis.sh": "0627a2187160fd1f8c9f618588f6398c4b087a91394e8d387f9a9710f79dd8a5", "cmd/arcrun-app/check-launcher.mjs": "0c9b4ebd142554b69c1eedf46e5618078b60a050b9f7e5afbf6d3c34d509337d", + "cmd/arcrun-app/check-remove-cleanup.sh": "b049881d1608b70fc0f27a130a1a794d780a522317d02ea8e17a866986c87c2c", "cmd/arcrun-app/check-render.sh": "8cd0fb4fe975111f74afb3bc841bc34fe9b160aeddcf67559a504c5187b8ae02", "cmd/arcrun-app/check-tray.sh": "7ff5c5e4d3dba6a356d9b556fc93cefe1c9e93827362ddd553fdb02717701cf5", "cmd/arcrun-app/connect.go": "0da69f6b781c3283851304511b1bd2ebd6f3b9663ae28b3605bab13072d39f9d", @@ -82,7 +88,7 @@ "cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c", "cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55", "cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494", - "cmd/arcrun-app/frontend/src/main.js": "9c448240ae49491081cb3dec467801fe7d02893f15be4a074f41542786173564", + "cmd/arcrun-app/frontend/src/main.js": "26c4ecf26b848916c96620a0e7aa6d91f1da208dd96597c08f39717b4e4eddee", "cmd/arcrun-app/frontend/src/style.css": "24b3797c731720b60ce8a2a19582b4a76e8e37a9d2eacce8dbe205ba4008849b", "cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c", "cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f", @@ -91,7 +97,8 @@ "cmd/arcrun-app/manifest_field_test.go": "8d7d742d54c5ef003d34ecd12f8d1e444e4e55c5dd0f8f8625f5e9c20cdf5285", "cmd/arcrun-app/quit_test.go": "c7b46b466ef5243cbecfaa236cecdc0c3466892a7dfadc6280e7a9c22f843389", "cmd/arcrun-app/quota_card_test.go": "6cc0afdb3de1fd002419523f80be0b290970bc965d68f7c1c63fa641ba10b488", - "cmd/arcrun-app/remove_folder_takedown_test.go": "46efb110a4f7afd89bab76072e17640e695772049391410f614a61110d8cd626", + "cmd/arcrun-app/remove_folder_cleanup_test.go": "e2d9636f92560f2f30fc256e7b27f5002e681ec07c3f6b348b344a4cdc22dbe8", + "cmd/arcrun-app/remove_folder_takedown_test.go": "6e30b13b9031e5d434bd1e38a0c8f66bc82ede5367123729529a8d35c982185f", "cmd/arcrun-app/selfupdate.go": "a1722cd1bfc90c8f068f3ee39f5aac43c7e9a5bf4a0144604482768e618065cb", "cmd/arcrun-app/selfupdate_test.go": "1c0752f3b3f4f262620e096558ded38ed21db94195b760a3f3b046ce9dbdd4ab", "cmd/arcrun-app/skipped_test.go": "f4c9de16c349c0b7f48ce62a6506364c87e3d4fbc99f415e3e6aec7d3da7f1aa", @@ -115,7 +122,7 @@ "convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394", "convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18", "convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13", - "direct.go": "98d51b233e31320de20f4c605ce420433fb4720136c9eb188fbd69e1a136fb69", + "direct.go": "1cf3a57ab5016a0fe7ad30c3e36789825f705de93456faeb237bee5e45e1c6ac", "direct_extract_test.go": "d3465c1b5953858ae780d192a2cf515d452f48eee3ff18a6345ce64320debbbb", "direct_multi_test.go": "d0aa211d90aaaa9d7dbe2b04f1aaa5989e91e91b037144cc0f4e0fd068f43c2b", "direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3", @@ -150,8 +157,8 @@ "machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56", "machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290", "machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0", - "main.go": "e02e7b32dd230f33e35c7ebf62d81b7599e2b86a0ef7cadc0fd34b672f42381d", - "manifest.go": "768049a34c06920711a980bb19fb41e7f1feff648f22265ba0c3d1c1ad85020f", + "main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10", + "manifest.go": "cbfaf5fc83b5a0ff8c2a62d776bb1a8e0d99367ec31a7fbc4081bd56d848c145", "manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e", "manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a", "multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66", @@ -166,7 +173,7 @@ "repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c", "safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57", "safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358", - "scan.go": "23993b1411af0a0308aa6614e514d8c60bcbeed6ed93087b9c5bcc2e0f4763e9", + "scan.go": "03a15d7dac31a4853e1bf1baf760da6d26d708e2805a533ccef5d117a438856e", "scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575", "scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362", "scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0", @@ -176,7 +183,7 @@ "supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3", "supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388", "supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7", - "sync_status.go": "5f35c17a01e07146b155081714200e07eb54e5e0de191be0b2208b9e13e92daa", + "sync_status.go": "59e8e2c81ec5a1cbd46e852bd7c794c4179c76ac62561f21550a41afb9e2be2d", "sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3", "t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51", "template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358", diff --git a/cmd/arcrun-app/.version-source.json b/cmd/arcrun-app/.version-source.json index 4e69e4f..86c1a4f 100644 --- a/cmd/arcrun-app/.version-source.json +++ b/cmd/arcrun-app/.version-source.json @@ -5,5 +5,7 @@ "0.18.32": "4753db807169fe3d", "0.18.33": "7ba0f6dd2d080925", "0.18.34": "d8c7490b9d32e62e", - "0.18.35": "3bd7f3fb0124bda4" + "0.18.35": "3bd7f3fb0124bda4", + "0.18.36": "abb2f2cc2498f071", + "0.18.37": "3bb5c94f68a26a06" } diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index 471d36c..25c0690 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -119,6 +119,9 @@ type syncStatus struct { // arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。 // 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。 Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"` + // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。 + // 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。 + Resync map[string]collector.ResyncStatus `json:"resync,omitempty"` } type skippedDoc struct { @@ -241,6 +244,10 @@ type UIFolder struct { Retiring bool `json:"retiring,omitempty"` RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆 RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫) + // arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。 + // 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到, + // 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。 + ResyncNote string `json:"resyncNote,omitempty"` // 一句人話(沒事=空字串,前端不畫) } type UIAccount struct { Name string `json:"name"` @@ -505,7 +512,13 @@ func (a *App) GetState() UIState { 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}) + uf := UIFolder{Path: f, AccIdx: i} + // arcrun-rag#140:雲端補送中就把那句人話帶到畫面上。 + // key=資料夾路徑,與 collector 寫入 status.json 時同一把(見 direct.go 的 resync[root])。 + if rs, ok := sync.Resync[f]; ok { + uf.ResyncNote = rs.Note + } + ui.Folders = append(ui.Folders, uf) } // 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失, // 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。 diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index 5d58352..b61291f 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -320,9 +320,10 @@ function pageLib(s, idx) { ${f.retireError ? `
${esc(f.retireError)}
` : ''}` : `
${esc(f.path)} - 自動同步中 + ${f.resyncNote ? '補送中' : '自動同步中'} -
`).join('') + + ${f.resyncNote ? `
${esc(f.resyncNote)}
` : ''}`).join('') || `
這個知識庫還沒有資料夾
按右上的「加入資料夾」,選一個要自動整理的資料夾。
`}`; } diff --git a/direct.go b/direct.go index 415ffd5..0ef6119 100644 --- a/direct.go +++ b/direct.go @@ -672,6 +672,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // 每輪重建、照現況重報(level-triggered),App 看到 done 才把設定裡那一筆清掉。 var retiring map[string]RetiringStatus + // `inkstone/arcrun-rag#140`:這一輪各個資料夾的「雲端補送」現況(key=資料夾路徑)。 + // 同 retiring 的 level-triggered 語意:每輪照 manifest 現況重報,補完自然消失。 + var resync map[string]ResyncStatus + // t215:全域「雲端最新版」只抓一次(自帶節流,見 cloud_latest.go)—— // 這是所有帳號共用的同一把尺,不是逐帳號各打一次。 latestRelease, latestOK := FetchLatestCloudRelease() @@ -733,6 +737,13 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now) totalProgress = totalProgress.Add(rp.Progress) stuckReasons = append(stuckReasons, rp.StuckReasons...) + // #140:有話要說才佔畫面(零值=這個資料夾沒有補送中的事)。 + if rp.Resync.Pending > 0 || rp.Resync.Repaired > 0 || rp.Resync.LastError != "" { + if resync == nil { + resync = map[string]ResyncStatus{} + } + resync[root] = rp.Resync + } if multi { for i := range r { r[i].Root = root @@ -828,6 +839,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge ExtractorError: extractorError, AccountDetails: accountDetails, Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度 + Resync: resync, // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾 } // G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。 // 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。 @@ -1204,6 +1216,10 @@ func accountsConnected(cfg *DirectConfig) bool { type rootProgress struct { Progress SyncProgress // 這一根的 Total/Done/Pending/Stuck(Unreadable 由呼叫端補,見 progress.go) StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類 + // Resync=這一根的「雲端補送」現況(#140)。**從 manifest 現況重算**,不是本輪計數 + // ——後者在沒事做的那輪會歸零,那正是 2026-08-05 leo 實撞的「明明做完了畫面卻寫等待中」。 + // 沒事=零值,呼叫端不寫進 status.json(不製造常駐噪音)。 + Resync ResyncStatus } // qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。 @@ -1255,6 +1271,52 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta if err != nil { return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{} } + // 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。 + // 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、 + // 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過, + // 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。 + // 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映 + // 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。 + // + // (#140:定義位置從「Scan() 之後」往上搬到這裡,因為雲端對帳跑在 Scan() 之前 + // 也要落盤——拔掉的章沒存進磁碟,process 被殺掉就等於沒對過帳。內容一字未動。) + saveManifest := func() { + if dryRun { + return + } + if serr := m.Save(absManifest); serr != nil { + results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()}) + exit = 1 + } + } + + // 🔴 雲端對帳(`inkstone/arcrun-rag#140`,2026-08-26)——**必須在 Scan() 之前**。 + // 它做的唯一一件事是把「雲端已經沒有了」的 ingested 章拔掉;拔完之後 + // 下面那個 Scan() 就會因為 `orig[p].IngestedHash == ""` 自然補一發 added 事件 + // (scan.go 步驟 4 的既有語意「上輪偵測過但 ingest 未成功 → 重試」) + // ⇒ 補送走的是**既有的**萃取路,既有的單輪上限/失敗退避/額度冷卻全部照舊生效。 + // 放在 Scan() 之後就得再等一輪才會動,且要另外發明一條送件路。見 cloud_audit.go。 + auditErr := "" + if ar := auditCloudLedger(cfg, absRoot, m, dryRun, runNow); ar != nil { + auditErr = ar.Err + if ar.Voided > 0 { + results = append(results, DirectResult{ + Type: "resync", Path: absRoot, Status: "noticed", + Error: fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(對帳了 %d 份,知識庫可能重裝過),已排進佇列自動補送", ar.Voided, ar.Checked), + }) + } + if ar.Err != "" { + // 上游錯誤原文照顯示(leo 2026-08-06:「別人的錯誤一律要顯示給用戶看, + // 不然就會變成我的錯誤」)。對帳失敗**不設 exit**:它是保險絲不是主流程, + // 連不上雲端時檔案同步照常走。 + results = append(results, DirectResult{ + Type: "resync", Path: absRoot, Status: "noticed", + Error: "無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試):" + ar.Err, + }) + } + saveManifest() // 拔掉的章與對帳時間當下就落盤(斷點續傳同款) + } + // 2026-08-07 task 3:Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意, // 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。 // 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的 @@ -1291,22 +1353,6 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta now := runNow.Unix() - // 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。 - // 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、 - // 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過, - // 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。 - // 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映 - // 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。 - saveManifest := func() { - if dryRun { - return - } - if serr := m.Save(absManifest); serr != nil { - results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()}) - exit = 1 - } - } - // 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把 // 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響** // ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在: @@ -1528,6 +1574,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta res.Status = "ingested" // 記下是誰萃的(t73/leo 07-27):換萃取器時才分辨得出哪些卡是舊的。 m.MarkIngestedBy(ev.Path, ev.SourceHash, now, cfg.Extractor) + // 🔴 #140:`cards` 為空=該檔被判「無可萃取概念」,這一輪**一張卡都沒上雲**。 + // 不記下來的話,雲端對帳每天都會查到「雲端沒有它」⇒ 每天重萃一次、 + // 永遠停不下來,而且每次都燒一份 AI 額度。 + if len(cards) == 0 { + m.MarkNoCloudCard(ev.Path) + } qs.DailyCount++ // 2026-08-07:今天的成就數(額度訊息「今天已經幫你整理了 N 份」用) } else { // t195:記下失敗並排定退避,否則下輪又把它當新檔重試 @@ -1678,6 +1730,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta rp.StuckReasons = append(rp.StuckReasons, e.LastError) } } + // #140:同一套「現況快照」邏輯——補送進度也是原地數出來的,不是本輪計數器。 + if pending, repaired := ResyncSummary(m, runNow); pending > 0 || repaired > 0 || auditErr != "" { + rp.Resync = ResyncStatus{ + Pending: pending, + Repaired: repaired, + LastError: auditErr, // 真因原文,不改寫(leo 2026-08-06) + Note: resyncNote(pending, repaired, auditErr), + } + if m.CloudAuditAt > 0 { + rp.Resync.CheckedAt = time.Unix(m.CloudAuditAt, 0).UTC().Format(time.RFC3339) + } + } return results, exit, payload, rp } diff --git a/manifest.go b/manifest.go index 7c2973e..0bfa329 100644 --- a/manifest.go +++ b/manifest.go @@ -56,6 +56,27 @@ type ManifestEntry struct { // 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失** // ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。 LastError string `json:"last_error,omitempty"` + + // ── 雲端對帳(`inkstone/arcrun-rag#140`,2026-08-26)────────────────────── + // 病:上面那個 IngestedHash 的章**永遠不會過期**。雲端 08-14 被重裝/清空之後, + // 檔案內容沒變 ⇒ 不產生事件 ⇒ 永遠不會重送,而且沒有任何地方會說話。 + // ⇒ 章的意思要從「我送過了」改成「我送過了**而且雲端現在還有**」, + // 後半句只能去問雲端(見 cloud_audit.go)。這三個欄位是那個問答的記帳。 + // + // CloudCheckedAt=最後一次向雲端確認過這個檔的時間(unix 秒)。 + // 用途有二:①決定「太久沒對帳」該重問(cloudAuditRecheckInterval); + // ②讓候選排序把最久沒對的排前面 ⇒ 巨量資料夾也會被輪完,不會有人永遠排不到。 + CloudCheckedAt int64 `json:"cloud_checked_at,omitempty"` + // CloudMissingAt=最後一次「雲端查不到、章被拔掉」的時間(unix 秒,0=從沒發生)。 + // 🔴 它同時是**防重送迴圈的閘**:雲端 ingest 非同步,剛送出去那幾秒查不到是正常的, + // 沒有這個窗口,對帳下一輪又判它不在 ⇒ 每輪重送 ⇒ 把使用者的 AI 額度燒光 + // ⇒ 那就是「把一個 bug 換成另一個」(#140 驗收條件 5 明文禁止)。 + CloudMissingAt int64 `json:"cloud_missing_at,omitempty"` + // NoCloudCard=這一次 ingest 蓋章時**沒有任何卡片被送上雲** + //(萃取判定「無可萃取概念」,cards 為空 ⇒ direct.go 的送卡迴圈一圈都沒跑)。 + // 🔴 不記這一格的話,對帳每天都會查到「雲端沒有它」⇒ 每天重萃一次、永遠停不下來。 + // 舊的 manifest 沒有這一格=當成「送過」,那正是本票要救的那批檔的處境。 + NoCloudCard bool `json:"no_cloud_card,omitempty"` } // retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。 @@ -101,6 +122,12 @@ type Manifest struct { // 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它, // 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。 PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"` + + // CloudAuditAt=這個資料夾最後一次跑雲端對帳批次的時間(unix 秒)。 + // 純節流用(cloudAuditFolderInterval):daemon 預設 5 秒一輪,沒有這道閘 + // 就是每 5 秒對雲端發 20 個請求。與 InventoryHash 同為 Manifest 層欄位, + // Scan() 的 rebuild 只重建 Entries,天然跨輪存活。 + CloudAuditAt int64 `json:"cloud_audit_at,omitempty"` } // QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。 @@ -186,6 +213,27 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s // 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。 e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0 e.LastError = "" + // #140:預設「這次有送卡上雲」;真的一張卡都沒送的那條路由呼叫端補打 + // MarkNoCloudCard(見 direct.go 的 cards 為空分支)。預設值放這裡而不是 + // 讓呼叫端每次都設,是因為漏設的方向要落在**安全的那一邊**: + // 誤標 false ⇒ 頂多多對一次帳(一個唯讀請求);誤標 true ⇒ 這個檔從此 + // 不再被對帳,雲端掉了也沒人發現——那正是本票在修的病。 + e.NoCloudCard = false + // 🔴 CloudCheckedAt/CloudMissingAt **刻意不清**: + // 前者是輪值排序的依據,後者是防重送迴圈的 grace 窗口與「補送過」的證據 + // (ResyncSummary 靠 IngestedAt >= CloudMissingAt 判斷這份是不是剛補回來的)。 + // 清掉等於把剛補送成功的那批檔立刻放回可再拔章的池子。 + return true +} + +// MarkNoCloudCard 標記「這次 ingest 沒有任何卡片被送上雲端」。 +// 只有萃取判定「無可萃取概念」(cards 為空)的那條路會呼叫。見 NoCloudCard 欄位註解。 +func (m *Manifest) MarkNoCloudCard(path string) bool { + e, ok := m.Entries[path] + if !ok { + return false + } + e.NoCloudCard = true return true } diff --git a/scan.go b/scan.go index b218a01..7c2954b 100644 --- a/scan.go +++ b/scan.go @@ -556,6 +556,15 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { // ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。 // 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。 ne.LastError = carry.LastError + // 🔴 #140(2026-08-26):雲端對帳的三個欄位同樣要 carry,而且漏了會**很貴**: + // - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批(請求風暴) + // - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章 + // → 再補送…把使用者的 AI 額度燒光(票上明文禁止的「把一個 bug 換成另一個」) + // - NoCloudCard 歸零 ⇒ 沒卡可送的檔每天被重萃一次,永遠停不下來 + // 這正是上面那句警告(t195/LastError 5fcc139)的第三次實例,所以照著它走。 + ne.CloudCheckedAt = carry.CloudCheckedAt + ne.CloudMissingAt = carry.CloudMissingAt + ne.NoCloudCard = carry.NoCloudCard } newEntries[p] = ne } diff --git a/sync_status.go b/sync_status.go index 332b018..2c95d48 100644 --- a/sync_status.go +++ b/sync_status.go @@ -52,6 +52,23 @@ type RetiringStatus struct { LastError string `json:"last_error,omitempty"` // 最後一次失敗的真因(原文,不改寫) } +// ResyncStatus=某個監看資料夾的「雲端補送」現況(`inkstone/arcrun-rag#140`)。 +// +// 🔴 為什麼一定要有畫面:這張票的病不只是「該送的沒送」,還有 +// 「**沒有任何地方會說話**」——使用者看到檔案在資料夾裡、AI 卻查不到, +// 而且查不出為什麼。所以修法不能靜悄悄地重送:他要看得見 +// 「有 N 份在補送、不是你弄壞的、你不必做任何事」。 +// +// 與 Retiring/FolderPlans 同族:**每輪照 manifest 現況重算的快照**, +// 不進 CarryForwardActivity——補完就自然歸零,不必有人去清它。 +type ResyncStatus struct { + Pending int `json:"pending"` // 章已拔掉、還沒補送成功的份數 + Repaired int `json:"repaired"` // 最近一天內已補送成功的份數 + CheckedAt string `json:"checked_at,omitempty"` // 最近一次對帳時間(RFC3339) + Note string `json:"note,omitempty"` // 給使用者看的一句人話 + LastError string `json:"last_error,omitempty"` // 對帳本身失敗的真因(原文,不改寫) +} + // SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。 // 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。 type SyncStatus struct { @@ -83,6 +100,10 @@ type SyncStatus struct { // 與 SkippedDocs 同族——每輪照現況重算的快照,不進 CarryForwardActivity。 Retiring map[string]RetiringStatus `json:"retiring,omitempty"` + // `inkstone/arcrun-rag#140`:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。 + // 與 Retiring 同族的現況快照,見 ResyncStatus 註解。 + Resync map[string]ResyncStatus `json:"resync,omitempty"` + // 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案, // 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。 // 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。