feat(collector): 結構先行——掃描完幾秒內就能問「資料夾裡有什麼」,不等 LLM 不等額度(InkStoneCo#43)
leo 08-15 量測:266 檔真正算的 ~20 分鐘/<$0.5,使用者卻等 ~4 天(多在等 Workers AI 每日額度);而「有哪些檔案/最近改了什麼」本地掃一遍就有答案 (1,335 檔實測 0.254s),卻因 part_of 只在雲端 parse_card(萃取之後)生成 而排同一條隊、撞同一面額度牆。 - collector/inventory.go:Scan() 後、萃取迴圈前(額度冷卻閘之外),把 manifest 現況做成機械總覽卡(檔案清單/最近改動/目錄 part_of)POST 既有 rag_ingest_card ——零 LLM、零新 workflow、既有實例直接受益;自帶同頁名 upsert 防重複 - 冪等:內容 sha256 記 manifest.inventory_hash;失敗 10 分鐘退避(防 t195 同款 每 5 秒重撞);額度失敗訊息人話化,不裸露 4006/502 - 清單 ≤200 逐檔、關聯 ≤10 條:守 Workers 免費層 50 subrequests 天花板 - 不寫本機檔案、不動使用者原稿 實測(youlin stage yuga3bse,刻意無金鑰=零萃取):總覽卡 200 送達、rag_chat 正確回答「有哪些檔案/最近改了什麼」;重掃不重複(blocks 恆 5、triplets 恆 2); go test collector+supervisor+arcrun-app 全綠(新增 inventory_test.go 6 案)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1040,6 +1040,22 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
}
|
||||
}
|
||||
|
||||
// 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把
|
||||
// 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響**
|
||||
// ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在:
|
||||
// ① 逐檔萃取迴圈之前——萃取可能要跑幾小時(積壓)甚至幾天(等額度),
|
||||
// 結構性問題不該跟它排同一條隊;
|
||||
// ② qs.inCooldown 的閘之外——額度撞牆期間這正是使用者唯一還能問的東西;
|
||||
// ③ 「removed 暫時放回」之前——總覽反映檔案系統**現況**,剛刪掉的檔
|
||||
// 不該還列在清單上(放回只是下架重試的記帳,不是現況)。
|
||||
// 冪等與失敗處理見 syncInventory 註解;失敗不設 exit(加值層壞了不擋檔案同步)。
|
||||
if invRes := syncInventory(cfg, absRoot, m, len(payload.Events) > 0, dryRun, runNow); invRes != nil {
|
||||
results = append(results, *invRes)
|
||||
if invRes.Status != "planned" {
|
||||
saveManifest() // 記住 inventory_hash/失敗退避(斷點續傳同款:當下就落盤)
|
||||
}
|
||||
}
|
||||
|
||||
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
|
||||
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
|
||||
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
|
||||
|
||||
+63
-29
@@ -84,37 +84,53 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
if exit != 0 {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "ingested" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
// 結構先行(InkStoneCo#43):每輪多一張機械總覽卡(零 LLM),檔案事件另計
|
||||
inv, fileResults := splitInventory(results)
|
||||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||||
t.Fatalf("總覽卡應送達:%+v", inv)
|
||||
}
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "ingested" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
// 卡片落地本地(用戶看得到自己的 wiki)
|
||||
if _, err := os.Stat(filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-報銷規則.md")); err != nil {
|
||||
t.Fatalf("卡片未落地:%v", err)
|
||||
}
|
||||
// 上雲的是卡片、不是原文
|
||||
if len(posted) != 1 {
|
||||
t.Fatalf("應恰好 POST 一張卡,got %d", len(posted))
|
||||
// 上雲的是「總覽卡+內容卡」兩張,都不含原文
|
||||
if len(posted) != 2 {
|
||||
t.Fatalf("應恰好 POST 兩張卡(總覽+內容),got %d", len(posted))
|
||||
}
|
||||
cc, _ := posted[0]["card_content"].(string)
|
||||
for _, p := range posted {
|
||||
if cc, _ := p["card_content"].(string); strings.Contains(cc, "XYZZY") {
|
||||
t.Fatal("原文內容洩上雲=違反四步定稿邊界")
|
||||
}
|
||||
}
|
||||
var contentCard map[string]any
|
||||
for _, p := range posted {
|
||||
if pn, _ := p["page_name"].(string); !strings.HasPrefix(pn, "資料夾總覽") {
|
||||
contentCard = p
|
||||
}
|
||||
}
|
||||
if contentCard == nil {
|
||||
t.Fatal("找不到內容卡")
|
||||
}
|
||||
cc, _ := contentCard["card_content"].(string)
|
||||
if !strings.Contains(cc, "## 一句話定義") {
|
||||
t.Fatalf("card_content 不是卡片:%.80s", cc)
|
||||
}
|
||||
if strings.Contains(cc, "XYZZY") {
|
||||
t.Fatal("原文內容洩上雲=違反四步定稿邊界")
|
||||
}
|
||||
// path 必須是「原檔路徑」(takedown 比對鍵+B4 溯源)——不是卡片路徑(07-24 第五枚坑)
|
||||
if p, _ := posted[0]["path"].(string); p != "報銷規則.md" {
|
||||
if p, _ := contentCard["path"].(string); p != "報銷規則.md" {
|
||||
t.Fatalf("path=%q(應為原檔路徑)", p)
|
||||
}
|
||||
// 🔴 arcrun-rag#60 第二輪:本機卡片檔名加了 arcrun- 前綴,但**上雲的 page_name 不准跟著變**。
|
||||
// 下架分支用的是原稿頁名(見下一支測試斷言 takedown page_name=="報銷規則"),
|
||||
// 這裡若跟著卡片檔名變成 "arcrun-報銷規則",兩邊就永遠對不上、刪原檔再也下架不掉。
|
||||
if pn, _ := posted[0]["page_name"].(string); pn != "報銷規則" {
|
||||
if pn, _ := contentCard["page_name"].(string); pn != "報銷規則" {
|
||||
t.Fatalf("page_name=%q(應為原稿頁名,不含 arcrun- 前綴,否則下架對不上)", pn)
|
||||
}
|
||||
// 第二輪:原稿沒變 → 不重萃不重送
|
||||
// 第二輪:原稿沒變 → 不重萃不重送(總覽卡雜湊相同也不重送)
|
||||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||||
if exit2 != 0 || len(results2) != 0 || len(posted) != 1 {
|
||||
if exit2 != 0 || len(results2) != 0 || len(posted) != 2 {
|
||||
t.Fatalf("第二輪應零事件:results=%+v posted=%d", results2, len(posted))
|
||||
}
|
||||
}
|
||||
@@ -170,8 +186,10 @@ func TestDirectExtractorRemovedClearsLocalCard(t *testing.T) {
|
||||
if exit != 0 {
|
||||
t.Fatalf("第二輪 exit=%d results=%+v", exit, results)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "removed" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
// 結構先行:刪檔輪總覽卡也會更新(清單不該還列著剛刪的檔),檔案事件另計
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "removed" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
if len(takedowns) != 1 {
|
||||
t.Fatalf("應恰好一次 takedown,got %d", len(takedowns))
|
||||
@@ -214,7 +232,8 @@ func TestDirectExtractorRemovedNoLocalCardOK(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 || len(results) != 1 || results[0].Status != "removed" {
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計
|
||||
if exit != 0 || len(fileResults) != 1 || fileResults[0].Status != "removed" {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
}
|
||||
@@ -237,13 +256,15 @@ func TestDirectExtractorFailKeepsRetry(t *testing.T) {
|
||||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 || len(results) != 1 || results[0].Status != "failed" {
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計(此處 cypher 不通,總覽也 failed)
|
||||
if exit != 1 || len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試)
|
||||
// 再跑一輪:仍是同一個事件(manifest 沒標 ingested=會重試);
|
||||
// 總覽卡則在自己的失敗退避窗口內,不重撞
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
if len(results2) != 1 {
|
||||
t.Fatalf("失敗檔應重試:%+v", results2)
|
||||
if inv2, fileResults2 := splitInventory(results2); len(fileResults2) != 1 || len(inv2) != 0 {
|
||||
t.Fatalf("失敗檔應重試、總覽應退避:%+v", results2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,9 +335,18 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var serverCalled bool
|
||||
// 結構先行後,總覽卡(只含檔名、零原文)照常會 POST 到 rag_ingest_card——
|
||||
// 本測試守的契約是「PDF 位元組/原文不出機」,改成逐請求驗內容與端點。
|
||||
var ingestDirectCalled bool
|
||||
var leaked bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverCalled = true
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.HasSuffix(r.URL.Path, "/rag_ingest_direct/trigger") {
|
||||
ingestDirectCalled = true
|
||||
}
|
||||
if strings.Contains(string(body), "%PDF") || strings.Contains(string(body), "機密原文") {
|
||||
leaked = true
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -337,16 +367,20 @@ func TestExtractorEmptyBlocksNonTextDirect(t *testing.T) {
|
||||
if exit != 1 {
|
||||
t.Fatalf("exit=%d,應是 1(非文字檔無萃取器=失敗)", exit)
|
||||
}
|
||||
if serverCalled {
|
||||
t.Error("防禦閘失效:PDF 被直送上雲(契約破壞)")
|
||||
if ingestDirectCalled {
|
||||
t.Error("防禦閘失效:走了舊制直送端點(契約破壞)")
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != "failed" {
|
||||
t.Fatalf("results=%+v", results)
|
||||
if leaked {
|
||||
t.Error("防禦閘失效:PDF 位元組/原文被送上雲(契約破壞)")
|
||||
}
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡(只含檔名)另計
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
// t182:這裡是「選了 Gemini 卻沒有金鑰」⇒ 停在萃取層、誠實報缺什麼。
|
||||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 serverCalled)。
|
||||
if !strings.Contains(results[0].Error, "gemini_api_key") {
|
||||
t.Errorf("錯誤訊息不符:%q", results[0].Error)
|
||||
// 本測真正要守的契約沒變、也仍然綠:**PDF 不得被直送上雲**(上面的 leaked)。
|
||||
if !strings.Contains(fileResults[0].Error, "gemini_api_key") {
|
||||
t.Errorf("錯誤訊息不符:%q", fileResults[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,9 @@ func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
|
||||
if r.Status != "planned" {
|
||||
t.Fatalf("dry-run 事件應為 planned:%+v", r)
|
||||
}
|
||||
if r.Type == "inventory" {
|
||||
continue // 結構先行:每根各有一張總覽卡(planned),檔案事件另計
|
||||
}
|
||||
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
|
||||
}
|
||||
if len(byRoot[rootA]) != 1 || byRoot[rootA][0] != "a.md" {
|
||||
|
||||
+13
-5
@@ -182,18 +182,24 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
t.Error("全部處理完不該再有延後交代")
|
||||
}
|
||||
|
||||
// 全部 8 個都真的送到雲端了(沒有一個被永久漏掉)
|
||||
// 全部 8 個都真的送到雲端了(沒有一個被永久漏掉);總覽卡(結構先行)另計
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(order) != 8 {
|
||||
t.Fatalf("雲端總共應收到 8 次卡片,got %d: %v", len(order), order)
|
||||
var contentCards []string
|
||||
for _, pn := range order {
|
||||
if !strings.HasPrefix(pn, "資料夾總覽") {
|
||||
contentCards = append(contentCards, pn)
|
||||
}
|
||||
}
|
||||
if len(contentCards) != 8 {
|
||||
t.Fatalf("雲端總共應收到 8 次卡片,got %d: %v", len(contentCards), order)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestedPaths(results []DirectResult) []string {
|
||||
var out []string
|
||||
for _, r := range results {
|
||||
if r.Status == "ingested" {
|
||||
if r.Status == "ingested" && r.Type != "inventory" { // 結構先行:總覽卡另計
|
||||
out = append(out, r.Path)
|
||||
}
|
||||
}
|
||||
@@ -228,7 +234,9 @@ func TestDirect_ResumeAfterInterruption(t *testing.T) {
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
mu.Lock()
|
||||
posted = append(posted, m["path"].(string))
|
||||
if p, _ := m["path"].(string); !strings.HasPrefix(p, ".arcrun-rag/") {
|
||||
posted = append(posted, p) // 結構先行:總覽卡(合成路徑)另計
|
||||
}
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
|
||||
+14
-2
@@ -58,8 +58,18 @@ func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
|
||||
if exit != 1 {
|
||||
t.Fatalf("兩份都該萃取失敗,exit 應為 1,got %d", exit)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results=%+v", results)
|
||||
// 結構先行:本測試的假伺服器對**所有**端點回額度 502,總覽卡也會失敗——
|
||||
// 它的錯誤同樣不准裸露錯誤碼(下方 banned 檢查涵蓋全部 results),
|
||||
// 但三句話(成就/出口)是萃取路 quotaState 的契約,總覽卡只講自己的狀態。
|
||||
inv, fileResults := splitInventory(results)
|
||||
if len(inv) != 1 || inv[0].Status != "failed" {
|
||||
t.Fatalf("inv=%+v", inv)
|
||||
}
|
||||
if !strings.Contains(inv[0].Error, "額度") || !strings.Contains(inv[0].Error, "不影響檔案同步") {
|
||||
t.Errorf("總覽卡的額度失敗要說人話:%q", inv[0].Error)
|
||||
}
|
||||
if len(fileResults) != 2 {
|
||||
t.Fatalf("results=%+v", fileResults)
|
||||
}
|
||||
|
||||
banned := []string{"4006", "502", "HTTP", "neurons"}
|
||||
@@ -69,6 +79,8 @@ func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
|
||||
t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range fileResults {
|
||||
// arcrun-rag#59:這支測試裡兩份檔案全部失敗、dailyCount 全程是 0——
|
||||
// 正是 leo 實查那個「一份都沒成功、額度卻用完」的情境,buildQuotaNotice
|
||||
// 改口不再承諾「自動恢復」(那是做不到的承諾)、也不再建議換模型
|
||||
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// inventory.go — 結構先行:掃描一結束就把「這個資料夾有哪些檔案/最近改了什麼」
|
||||
// 做成**機械總覽卡**送上知識庫(InkStoneCo#43,2026-08-15)。
|
||||
//
|
||||
// 為什麼要有這個(leo 2026-08-15 量測):266 個可萃檔真正花在算的只有 ~20 分鐘、
|
||||
// 成本 < $0.5,但使用者實際等了 ~4 天——多數時間在等 Workers AI 每日額度隔天恢復。
|
||||
// 而「這裡面有哪些檔案/最近改了什麼」這種**本地掃一遍就有答案、免費、秒級**的問題
|
||||
// (實測 1,335 檔含逐檔 SHA256 只要 0.254 秒),原本跟「這份文件在講什麼」排同一條隊、
|
||||
// 撞同一面額度牆——因為 part_of 目錄邊只在雲端 parse_card(LLM 萃取之後)才會長出來。
|
||||
//
|
||||
// 設計取捨(為什麼走 rag_ingest_card、不開新 workflow):
|
||||
// - 每個用戶實例**本來就裝著** rag_ingest_card(compile-workflows.mjs 的四條之一),
|
||||
// 走它=今天所有既有實例直接受益,不需要任何雲端部署/升級。
|
||||
// - 它自帶同頁名 upsert(pick_stale 先刪舊再寫)=重送不堆副本,冪等免費拿到。
|
||||
// - 它零 LLM(機械切塊+寫 KBDB)=不吃 neurons、額度冷卻期間照常可用。
|
||||
// - 卡片的「## 關聯」段天生會被 parse 成 triplets ⇒ 目錄結構直接長進圖譜,
|
||||
// rag-chat 的 fetch_triplets/kw_search 都查得到,檢索端零改動。
|
||||
//
|
||||
// 紅線對齊:本檔**不寫任何本機檔案**(總覽卡只存在於雲端),不動使用者原稿;
|
||||
// 資料一律走 webhook API(KBDB API-as-Wall,D38)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxInventoryListed:「## 檔案清單」逐檔點名的上限。超過就改列各目錄份數——
|
||||
// 與 scan.go maxOtherNames 同一個判準:少量點名有資訊量,巨量點名是噪音;
|
||||
// 也守住 rag_ingest_card 端的量(block 過大/foreach 過多都會踩雲端上限,
|
||||
// 見 wiki mistakes「http_request 零件抓大回應直接爆」)。
|
||||
maxInventoryListed = 200
|
||||
// maxInventoryRecent:「## 最近改了什麼」條數上限。
|
||||
maxInventoryRecent = 20
|
||||
// maxInventoryRels:「## 關聯」(目錄 part_of)條數上限。刻意壓低:
|
||||
// rag_ingest_card 對每條 rel 各發一個 POST(foreach),加上 upsert 前置的
|
||||
// 逐筆 DELETE,rel 太多會撞 Workers 免費層 50 subrequests/請求的天花板。
|
||||
maxInventoryRels = 10
|
||||
// maxInventoryDirLines:巨量模式下「各目錄份數」最多列幾行。
|
||||
maxInventoryDirLines = 30
|
||||
)
|
||||
|
||||
// inventoryPageName 回傳總覽卡的頁名。含資料夾 basename=人看得懂「這是哪個資料夾」;
|
||||
// 不同監看根的 basename 相同時靠 library(每根唯一)分頁,見 inventoryCardPath。
|
||||
func inventoryPageName(absRoot string) string {
|
||||
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
|
||||
if base == "" || base == "." || base == "/" {
|
||||
base = "知識資料夾"
|
||||
}
|
||||
return "資料夾總覽:" + base
|
||||
}
|
||||
|
||||
// inventoryCardPath 回傳總覽卡的合成路徑(雲端 source_uri 用的鍵,**不落地本機**)。
|
||||
// 掛在 .arcrun-rag/(機器工作區目錄,machinemark.go 的既有標記慣例)底下=一眼可辨
|
||||
// 「這是 Arcrun 產的、不是使用者的檔」;帶 library(每個監看根唯一,librarySlug 有
|
||||
// 雜湊後備)=同帳號多資料夾時 source_uri 不互撞。
|
||||
func inventoryCardPath(library string) string {
|
||||
return ".arcrun-rag/wiki/cards/arcrun-資料夾總覽-" + library + ".md"
|
||||
}
|
||||
|
||||
// invFile 是總覽卡的最小輸入(從 manifest entries 抽出,便於排序與測試)。
|
||||
type invFile struct {
|
||||
path string
|
||||
mtime int64
|
||||
}
|
||||
|
||||
// invDateOf 把 mtime 格式化成使用者看的日期(本機時區;使用者要的是「哪天改的」量級)。
|
||||
func invDateOf(mtime int64) string {
|
||||
if mtime <= 0 {
|
||||
return "(日期不明)"
|
||||
}
|
||||
return time.Unix(mtime, 0).Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// invTopDirOf 回傳相對路徑的第一層目錄;根層檔案回「(根目錄)」。
|
||||
func invTopDirOf(rel string) string {
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
if i := strings.IndexByte(rel, '/'); i > 0 {
|
||||
return rel[:i]
|
||||
}
|
||||
return "(根目錄)"
|
||||
}
|
||||
|
||||
// BuildInventoryCard 把 manifest entries(Scan 後=檔案系統現況)做成一張機械總覽卡。
|
||||
// 純函式、零 IO、輸出確定(同一份輸入永遠同一份輸出)——冪等判斷(內容雜湊)與
|
||||
// 測試都靠這一點。回傳(頁名, 卡片 markdown)。
|
||||
func BuildInventoryCard(absRoot string, entries map[string]*ManifestEntry, library string) (string, string) {
|
||||
page := inventoryPageName(absRoot)
|
||||
|
||||
files := make([]invFile, 0, len(entries))
|
||||
for p, e := range entries {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, invFile{path: strings.ReplaceAll(p, "\\", "/"), mtime: e.Mtime})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
|
||||
|
||||
var b strings.Builder
|
||||
sep := " " + strings.Repeat(">", 2) + " " // 三元組分隔符(組字串產生,同 lint_test 慣例)
|
||||
|
||||
b.WriteString("# " + page + "\n\n")
|
||||
|
||||
// ── 一句話定義:總數+格式分佈 ──
|
||||
extCount := map[string]int{}
|
||||
for _, f := range files {
|
||||
ext := strings.ToLower(path.Ext(f.path))
|
||||
if ext == "" {
|
||||
ext = "(無副檔名)"
|
||||
}
|
||||
extCount[ext]++
|
||||
}
|
||||
exts := make([]string, 0, len(extCount))
|
||||
for e := range extCount {
|
||||
exts = append(exts, e)
|
||||
}
|
||||
sort.Slice(exts, func(i, j int) bool {
|
||||
if extCount[exts[i]] != extCount[exts[j]] {
|
||||
return extCount[exts[i]] > extCount[exts[j]]
|
||||
}
|
||||
return exts[i] < exts[j]
|
||||
})
|
||||
extParts := []string{}
|
||||
for i, e := range exts {
|
||||
if i >= 5 {
|
||||
rest := 0
|
||||
for _, e2 := range exts[i:] {
|
||||
rest += extCount[e2]
|
||||
}
|
||||
extParts = append(extParts, fmt.Sprintf("其他 %d 份", rest))
|
||||
break
|
||||
}
|
||||
extParts = append(extParts, fmt.Sprintf("%s %d 份", strings.TrimPrefix(e, "."), extCount[e]))
|
||||
}
|
||||
b.WriteString("## 一句話定義\n")
|
||||
if len(files) == 0 {
|
||||
b.WriteString("這個資料夾目前沒有可收錄的文件(本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取)。\n\n")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("這個資料夾目前有 %d 份文件(%s),本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取。\n\n",
|
||||
len(files), strings.Join(extParts, "、")))
|
||||
}
|
||||
|
||||
// ── 最近改了什麼:mtime 新到舊,路徑字母序破平 ──
|
||||
recent := make([]invFile, len(files))
|
||||
copy(recent, files)
|
||||
sort.Slice(recent, func(i, j int) bool {
|
||||
if recent[i].mtime != recent[j].mtime {
|
||||
return recent[i].mtime > recent[j].mtime
|
||||
}
|
||||
return recent[i].path < recent[j].path
|
||||
})
|
||||
if n := len(recent); n > maxInventoryRecent {
|
||||
recent = recent[:maxInventoryRecent]
|
||||
}
|
||||
b.WriteString("## 最近改了什麼\n")
|
||||
if len(recent) == 0 {
|
||||
b.WriteString("(目前沒有檔案)\n")
|
||||
}
|
||||
for _, f := range recent {
|
||||
b.WriteString("- " + invDateOf(f.mtime) + " " + f.path + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── 檔案清單:少量逐檔點名(含日期),巨量改列各目錄份數+每目錄最新一檔 ──
|
||||
dirCount := map[string]int{}
|
||||
dirNewest := map[string]invFile{}
|
||||
for _, f := range files {
|
||||
d := invTopDirOf(f.path)
|
||||
dirCount[d]++
|
||||
if cur, ok := dirNewest[d]; !ok || f.mtime > cur.mtime || (f.mtime == cur.mtime && f.path < cur.path) {
|
||||
dirNewest[d] = f
|
||||
}
|
||||
}
|
||||
dirs := make([]string, 0, len(dirCount))
|
||||
for d := range dirCount {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
if dirCount[dirs[i]] != dirCount[dirs[j]] {
|
||||
return dirCount[dirs[i]] > dirCount[dirs[j]]
|
||||
}
|
||||
return dirs[i] < dirs[j]
|
||||
})
|
||||
|
||||
b.WriteString("## 檔案清單\n")
|
||||
switch {
|
||||
case len(files) == 0:
|
||||
b.WriteString("(目前沒有檔案)\n")
|
||||
case len(files) <= maxInventoryListed:
|
||||
for _, f := range files {
|
||||
b.WriteString("- " + f.path + "(" + invDateOf(f.mtime) + ")\n")
|
||||
}
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("共 %d 份,檔案較多,以下列各目錄的份數與最新一檔:\n", len(files)))
|
||||
for i, d := range dirs {
|
||||
if i >= maxInventoryDirLines {
|
||||
rest := 0
|
||||
for _, d2 := range dirs[i:] {
|
||||
rest += dirCount[d2]
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- (其餘 %d 個目錄共 %d 份)\n", len(dirs)-i, rest))
|
||||
break
|
||||
}
|
||||
nf := dirNewest[d]
|
||||
b.WriteString(fmt.Sprintf("- %s:%d 份(最新:%s,%s)\n", d, dirCount[d], nf.path, invDateOf(nf.mtime)))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// ── 關聯:目錄 part_of 總覽頁(parse_card 會再自動補「總覽頁 part_of knowledge-base」)──
|
||||
b.WriteString("## 關聯\n")
|
||||
relDirs := dirs
|
||||
if len(relDirs) > maxInventoryRels {
|
||||
relDirs = relDirs[:maxInventoryRels]
|
||||
}
|
||||
for _, d := range relDirs {
|
||||
if d == "(根目錄)" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("- " + d + sep + "part_of" + sep + page + "\n")
|
||||
}
|
||||
|
||||
return page, b.String()
|
||||
}
|
||||
|
||||
// inventoryRetryDelay:總覽卡送失敗後,同一份內容多久以後才准再試。
|
||||
// 積壓卡住時每輪都有事件(hasEvents 恆真),沒有這道退避,雲端一壞就是
|
||||
// 每 5 秒撞一次(t195 的 1387 輪教訓,同款病)。內容變了(雜湊不同)不受此限。
|
||||
const inventoryRetryDelay = 10 * time.Minute
|
||||
|
||||
// syncInventory 在每輪掃描後、逐檔萃取**之前**送總覽卡。回 nil=這輪不必送。
|
||||
//
|
||||
// 冪等/防撞四層:①卡片內容 sha256 記在 manifest.inventory_hash,內容沒變不送;
|
||||
// ②同一份內容剛送失敗過(inventory_fail_hash)→ 10 分鐘內不重試;
|
||||
// ③已有成功版本且本輪無事件 → 不送(總覽跟著變動走,不跟著輪詢走);
|
||||
// ④雲端 rag_ingest_card 自帶同頁名 upsert,就算重送也不堆副本。
|
||||
//
|
||||
// 失敗不擋同步主流程(不設 exit):總覽是加值層,它壞了檔案同步照常走;
|
||||
// 但誠實回報 failed result,不假綠。額度用完的失敗換成人話(不裸露 4006/502,
|
||||
// 同 2026-08-07 task 2 的判準),其餘失敗保留原文(遠端查得到真因,08-06 教訓)。
|
||||
func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) *DirectResult {
|
||||
lib := cfg.libraryFor(absRoot)
|
||||
page, card := BuildInventoryCard(absRoot, m.Entries, lib)
|
||||
if len(m.Entries) == 0 && m.InventoryHash == "" {
|
||||
return nil // 空資料夾且從沒公告過=沒東西好說
|
||||
}
|
||||
sum := sha256.Sum256([]byte(card))
|
||||
h := "sha256:" + hex.EncodeToString(sum[:])
|
||||
if h == m.InventoryHash {
|
||||
return nil // 冪等①:內容沒變(也涵蓋大量刪除防呆輪:entries 保留=雜湊不變)
|
||||
}
|
||||
if h == m.InventoryFailHash && now.Unix() < m.InventoryNextRetry {
|
||||
return nil // 防撞②:同一份內容剛失敗過,退避窗口內不重撞
|
||||
}
|
||||
if !hasEvents && m.InventoryHash != "" {
|
||||
return nil // ③:已有成功版本的靜止輪不重送
|
||||
}
|
||||
res := &DirectResult{Type: "inventory", Path: inventoryCardPath(lib)}
|
||||
if dryRun {
|
||||
res.Status = "planned"
|
||||
return res
|
||||
}
|
||||
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
|
||||
wf := cfg.CardIngestWF
|
||||
if wf == "" {
|
||||
wf = "rag_ingest_card"
|
||||
}
|
||||
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
|
||||
"page_name": page,
|
||||
"path": res.Path,
|
||||
"card_content": card,
|
||||
"library": lib,
|
||||
})
|
||||
res.HTTPStatus = status
|
||||
if err != nil {
|
||||
res.Status = "failed"
|
||||
if isQuotaExhausted(err.Error()) {
|
||||
// 額度訊息不裸露錯誤碼(同 task 2 判準);額度的完整三句話由萃取路的
|
||||
// quotaState 負責,這裡只講「這件事」的狀態與下一步。
|
||||
res.Error = "雲端今天的額度用完了,資料夾總覽會稍後自動補送(不影響檔案同步)"
|
||||
} else {
|
||||
res.Error = "資料夾總覽上傳失敗(不影響檔案同步):" + err.Error()
|
||||
}
|
||||
m.InventoryFailHash = h
|
||||
m.InventoryNextRetry = now.Add(inventoryRetryDelay).Unix()
|
||||
return res
|
||||
}
|
||||
res.Status = "ingested"
|
||||
m.InventoryHash = h
|
||||
m.InventoryFailHash = ""
|
||||
m.InventoryNextRetry = 0
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// inventory_test.go — 結構先行(InkStoneCo#43):資料夾總覽卡的單元與整合測試。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// splitInventory 把 results 拆成(總覽卡, 其餘)。既有測試斷言「幾個檔案事件」時
|
||||
// 用 rest;要驗結構先行本身時用 inv。
|
||||
func splitInventory(results []DirectResult) (inv, rest []DirectResult) {
|
||||
for _, r := range results {
|
||||
if r.Type == "inventory" {
|
||||
inv = append(inv, r)
|
||||
} else {
|
||||
rest = append(rest, r)
|
||||
}
|
||||
}
|
||||
return inv, rest
|
||||
}
|
||||
|
||||
func invEntries(paths map[string]int64) map[string]*ManifestEntry {
|
||||
m := map[string]*ManifestEntry{}
|
||||
for p, mt := range paths {
|
||||
m[p] = &ManifestEntry{ContentHash: "sha256:x", Size: 1, Mtime: mt}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 基本結構:四段齊全、逐檔點名、格式統計、目錄 part_of 三元組。
|
||||
func TestBuildInventoryCard_基本結構(t *testing.T) {
|
||||
day := time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local)
|
||||
entries := invEntries(map[string]int64{
|
||||
"報銷規則.md": day.Unix(),
|
||||
"docs/流程.md": day.Add(24 * time.Hour).Unix(),
|
||||
"docs/簡報.pptx": day.Add(48 * time.Hour).Unix(),
|
||||
})
|
||||
page, card := BuildInventoryCard("/Users/x/我的筆記", entries, "kb")
|
||||
if page != "資料夾總覽:我的筆記" {
|
||||
t.Fatalf("page=%q", page)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# 資料夾總覽:我的筆記",
|
||||
"## 一句話定義",
|
||||
"這個資料夾目前有 3 份文件",
|
||||
"md 2 份",
|
||||
"pptx 1 份",
|
||||
"## 最近改了什麼",
|
||||
"- 2026-08-12 docs/簡報.pptx", // 最新在最上面
|
||||
"## 檔案清單",
|
||||
"- docs/流程.md(2026-08-11)",
|
||||
"- 報銷規則.md(2026-08-10)",
|
||||
"## 關聯",
|
||||
"- docs " + strings.Repeat(">", 2) + " part_of " + strings.Repeat(">", 2) + " 資料夾總覽:我的筆記",
|
||||
} {
|
||||
if !strings.Contains(card, want) {
|
||||
t.Errorf("卡片缺 %q\n----\n%s", want, card)
|
||||
}
|
||||
}
|
||||
// 最近改動的排序:簡報(08-12)要出現在 流程(08-11)之前
|
||||
if strings.Index(card, "docs/簡報.pptx") > strings.Index(card, "docs/流程.md") {
|
||||
t.Error("「最近改了什麼」未按 mtime 新到舊排序")
|
||||
}
|
||||
}
|
||||
|
||||
// 確定性:同一份輸入永遠同一份輸出(冪等雜湊的前提)。
|
||||
func TestBuildInventoryCard_確定性(t *testing.T) {
|
||||
entries := invEntries(map[string]int64{"a.md": 100, "b/c.md": 200, "b/d.pdf": 200})
|
||||
_, c1 := BuildInventoryCard("/x/root", entries, "kb")
|
||||
for i := 0; i < 10; i++ {
|
||||
_, c2 := BuildInventoryCard("/x/root", entries, "kb")
|
||||
if c1 != c2 {
|
||||
t.Fatal("同一份輸入產出不同卡片(map 迭代順序洩漏)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 巨量資料夾:不逐檔點名(改列各目錄份數),關聯不超上限——守雲端 subrequest 天花板。
|
||||
func TestBuildInventoryCard_巨量只列摘要(t *testing.T) {
|
||||
entries := map[string]*ManifestEntry{}
|
||||
for d := 0; d < 40; d++ {
|
||||
for f := 0; f < 10; f++ {
|
||||
p := fmt.Sprintf("dir%02d/f%02d.md", d, f)
|
||||
entries[p] = &ManifestEntry{Mtime: int64(1000 + d + f)}
|
||||
}
|
||||
}
|
||||
_, card := BuildInventoryCard("/x/big", entries, "kb")
|
||||
if !strings.Contains(card, "共 400 份,檔案較多") {
|
||||
t.Error("巨量模式應改列摘要")
|
||||
}
|
||||
if strings.Count(card, "- dir")-strings.Count(card, "- dir00 ") > maxInventoryDirLines+maxInventoryRels {
|
||||
t.Error("目錄行數超出上限")
|
||||
}
|
||||
sep := " " + strings.Repeat(">", 2) + " "
|
||||
if n := strings.Count(card, sep+"part_of"+sep); n > maxInventoryRels {
|
||||
t.Errorf("關聯 %d 條超過上限 %d(會撞雲端 subrequest 上限)", n, maxInventoryRels)
|
||||
}
|
||||
if !strings.Contains(card, "其餘") {
|
||||
t.Error("被截斷的目錄應誠實說有其餘幾個(不安靜略過)")
|
||||
}
|
||||
}
|
||||
|
||||
// 整合:總覽卡必須先於任何萃取/收卡 POST 送達(結構不跟 LLM 排同一條隊),
|
||||
// 且第二輪(無變動)不重送=冪等。
|
||||
func TestDirectInventory_先於萃取且冪等(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "規則.md"), []byte("# 內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string // 按抵達順序記 page_name
|
||||
srv := 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)
|
||||
pn, _ := m["page_name"].(string)
|
||||
order = append(order, pn)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaCardStub(t, cardFixture("規則", "kb"))()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 0 {
|
||||
t.Fatalf("exit=%d results=%+v", exit, results)
|
||||
}
|
||||
if len(order) != 2 {
|
||||
t.Fatalf("應恰好兩個 POST(總覽卡+內容卡),got %v", order)
|
||||
}
|
||||
if !strings.HasPrefix(order[0], "資料夾總覽:") {
|
||||
t.Fatalf("總覽卡必須先送(結構先行),實際順序:%v", order)
|
||||
}
|
||||
if order[1] != "規則" {
|
||||
t.Fatalf("內容卡未送達:%v", order)
|
||||
}
|
||||
inv, _ := splitInventory(results)
|
||||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||||
t.Fatalf("inv=%+v", inv)
|
||||
}
|
||||
// 第二輪:什麼都沒變 → 總覽不重送、也沒有檔案事件
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
if len(results2) != 0 || len(order) != 2 {
|
||||
t.Fatalf("無變動輪不該重送:results2=%+v order=%v", results2, order)
|
||||
}
|
||||
// 新增一個檔 → 總覽跟著更新(清單多一筆)+新檔照常萃取。
|
||||
// (附帶驗證:同一天內改同一個檔,總覽內容在日粒度下不變=不重送,是冪等的正確行為)
|
||||
if err := os.WriteFile(filepath.Join(root, "新頁.md"), []byte("# 新內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exit3, _ := RunDirectOnce(cfg, false); exit3 != 0 {
|
||||
t.Fatal("第三輪失敗")
|
||||
}
|
||||
if len(order) != 4 || !strings.HasPrefix(order[2], "資料夾總覽:") {
|
||||
t.Fatalf("變動後總覽應更新且仍先送:%v", order)
|
||||
}
|
||||
}
|
||||
|
||||
// 整合:萃取整條路壞掉(連 Gemini 都連不上)時,總覽卡照樣送達——
|
||||
// 這正是「不必等 LLM、不必等額度」要保的那條路。
|
||||
func TestDirectInventory_萃取壞掉照樣送達(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var invPosted int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(body), "資料夾總覽") {
|
||||
invPosted++
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
// Gemini 替身回 500=萃取全滅
|
||||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})()
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if exit != 1 {
|
||||
t.Fatalf("萃取失敗 exit 應為 1,got %d", exit)
|
||||
}
|
||||
if invPosted != 1 {
|
||||
t.Fatalf("萃取壞掉時總覽卡應照樣送達,got %d", invPosted)
|
||||
}
|
||||
inv, rest := splitInventory(results)
|
||||
if len(inv) != 1 || inv[0].Status != "ingested" {
|
||||
t.Fatalf("inv=%+v", inv)
|
||||
}
|
||||
if len(rest) != 1 || rest[0].Status != "failed" {
|
||||
t.Fatalf("rest=%+v", rest)
|
||||
}
|
||||
}
|
||||
|
||||
// 失敗退避:總覽送失敗後,同一份內容在退避窗口內不重撞(積壓輪每 5 秒都有事件,
|
||||
// 沒有這道就是 t195 的 1387 輪重演);內容變了立即可再試。
|
||||
func TestSyncInventory_失敗退避(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
m := &Manifest{Entries: invEntries(map[string]int64{"a.md": 100})}
|
||||
cfg := &DirectConfig{CypherURL: "https://x.invalid", Namespace: "demo", APIKey: "demo", Library: "kb"}
|
||||
now := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
res := syncInventory(cfg, root, m, true, false, now)
|
||||
if res == nil || res.Status != "failed" {
|
||||
t.Fatalf("res=%+v", res)
|
||||
}
|
||||
if m.InventoryFailHash == "" || m.InventoryNextRetry == 0 {
|
||||
t.Fatal("失敗應記退避")
|
||||
}
|
||||
// 同內容、退避窗口內 → 不重試
|
||||
if res2 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res2 != nil {
|
||||
t.Fatalf("退避窗口內不該重試:%+v", res2)
|
||||
}
|
||||
// 退避窗口過了 → 重試
|
||||
if res3 := syncInventory(cfg, root, m, true, false, now.Add(inventoryRetryDelay+time.Minute)); res3 == nil {
|
||||
t.Fatal("退避過期後應重試")
|
||||
}
|
||||
// 內容變了 → 立即可再試
|
||||
m.Entries["b.md"] = &ManifestEntry{Mtime: 200}
|
||||
if res4 := syncInventory(cfg, root, m, true, false, now.Add(time.Minute)); res4 == nil {
|
||||
t.Fatal("內容變了應立即重試(新卡不受舊卡退避拖累)")
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@ type Manifest struct {
|
||||
FolderID string `json:"folder_id"`
|
||||
Root string `json:"root"`
|
||||
Entries map[string]*ManifestEntry `json:"entries"`
|
||||
// InventoryHash=最後一次**成功送達雲端**的資料夾總覽卡內容雜湊(結構先行,
|
||||
// InkStoneCo#43,見 inventory.go)。與 entries 的跨輪 carry 陷阱無關——這是
|
||||
// Manifest 層欄位,Scan() 的 rebuild 只重建 Entries,不會碰它,天然跨輪存活。
|
||||
InventoryHash string `json:"inventory_hash,omitempty"`
|
||||
// InventoryFailHash/InventoryNextRetry=總覽卡上一次送失敗的內容雜湊與下次可重試
|
||||
// 時間(unix)。存在理由=t195 同款:積壓卡住時每輪都有事件,沒有這道退避,
|
||||
// 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。
|
||||
InventoryFailHash string `json:"inventory_fail_hash,omitempty"`
|
||||
InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"`
|
||||
}
|
||||
|
||||
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。
|
||||
|
||||
@@ -76,7 +76,8 @@ func TestScanSkipsTemplateArtifacts(t *testing.T) {
|
||||
if exit != 0 {
|
||||
t.Fatalf("dry-run 失敗:%+v", results)
|
||||
}
|
||||
if len(results) != 1 || results[0].Path != "我的筆記.md" {
|
||||
_, fileResults := splitInventory(results) // 結構先行:總覽卡另計
|
||||
if len(fileResults) != 1 || fileResults[0].Path != "我的筆記.md" {
|
||||
t.Fatalf("應只掃到用戶檔(template 產物須跳過),got %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user