516 lines
25 KiB
Go
516 lines
25 KiB
Go
// foldertree.go — 把「這個看守資料夾長什麼樣」做成**結構化的樹**送上雲端
|
||
// (InkStoneCo#44 線 A,leo 2026-08-17 規格)。
|
||
//
|
||
// 🔴 leo 的原話就是這支檔的規格:
|
||
//
|
||
// 「昨天我看到的是掃到了指定資料夾,但 **tree 在 portal 上應該顯示該資料夾的 tree**,
|
||
// 並顯示該資料夾**每個子資料夾的「同步文件數/總文件數」**,
|
||
// 不上傳通常是不支援,比如程式碼、不支援的格式。」
|
||
//
|
||
// ⇒ 這個畫面同時要回答「哪些沒上傳、為什麼」。使用者不必問人、也不必看 log。
|
||
//
|
||
// ── 為什麼不是重用 inventory.go 的總覽卡 ─────────────────────────────────────
|
||
// `inventory.go` 已經在送一張「資料夾總覽」的**知識卡**(markdown),但它服務的是
|
||
// **檢索**(讓 rag-chat 查得到「這個資料夾有什麼」),而且:
|
||
// - 它只數 manifest entries =**只數收得下的檔**,算不出「總文件數」的分母;
|
||
// - 它只到第一層目錄,沒有樹;
|
||
// - 它是一段 markdown ⇒ portal 沒辦法拿它畫出可摺疊、每節點兩個數字的樹。
|
||
//
|
||
// ⇒ 兩者消費者不同(一個給 LLM 檢索、一個給 portal 畫面),資料形狀也不同。
|
||
// **但分子分母只有一個來源**:本檔的分母(總數/不支援/被排除)由 `Scan()` 走訪時
|
||
// 順手數出來(同一趟走訪、同一套判準,見 scan.go 的 dirStat),分子(已同步)由
|
||
// manifest 現況導出 ⇒ 不會出現「畫面說 A、檔案說 B」那種兩份實作漂移的病。
|
||
//
|
||
// ── 為什麼「空資料夾」是這支檔的一等公民 ────────────────────────────────────
|
||
// `arcrun-rag#106`(leo 2026-08-15):
|
||
//
|
||
// 「如果指定同步的是**空資料夾**,雲端不出現,**這個不行**,因為
|
||
// 我指定資料夾雲端和地端是 navigate 的功能,
|
||
// **不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?**」
|
||
//
|
||
// 今天雲端的庫是「有卡才有庫」的副作用 ⇒ 空資料夾指定了什麼都不會出現。
|
||
// 本檔**每一輪都送**(內容沒變才跳過),而且**沒有檔案時照送**——所以指定的當下
|
||
// 那個資料夾就在雲端存在了,內容是 0。收端負責順手把庫登記起來(冪等)。
|
||
//
|
||
// 紅線對齊:本檔不寫任何本機檔案、不動使用者原稿;資料一律走既有的 portal daemon
|
||
// HTTP 端點(與 `/portal/daemon/extract`、`/portal/daemon/libraries` 同一族,
|
||
// 同一把 `X-Arcrun-API-Key`),不新開第二套上行機制。
|
||
package collector
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// MaxFolderTreeNodes:一棵樹最多送幾個節點。
|
||
//
|
||
// 為什麼要有上限:leo 的 `InkStoneCo` 一個資料夾就掃到 8,339 份檔,目錄數同量級。
|
||
// 一次把幾千個節點推上去,既撞 Workers 的 subrequest 天花板,也會把 portal 畫成
|
||
// 一面沒人看得完的牆。
|
||
//
|
||
// 🔴 **超過不准安靜地截掉**——`FolderTree.Truncated` 會被送上去、由畫面講出來
|
||
//(同 #104「排除規則要看得見」那條紅線:安靜地少講與講一個 0,對使用者是同一件事)。
|
||
//
|
||
// 🔴 2026-08-28(inkstone/Arcrun#180)從 300 提到 2000。leo 的規格是
|
||
// 「**雲端看到所有的 folder**」,而剪枝拿掉之後節點會變多——300 會讓「看得到全部」
|
||
// 在大資料夾上靜默失效(`Truncated` 雖然誠實,但那是在說「我做不到你要的事」)。
|
||
//
|
||
// **實測(本次改動後,同一台機器)**:
|
||
//
|
||
// youlinhsieh-test1 12 個節點(地端 `find -type d` 非隱藏 12)
|
||
// youlinhsieh-test2 13(地端 13)
|
||
// pms 53(地端 360——差額是 node_modules/dist/.gitignore 宣告的,
|
||
// 那幾種仍然剪枝,但節點與理由都在)
|
||
// InkStoneCo 62(地端 14,923,同上)
|
||
//
|
||
// ⇒ 真實資料夾離 300 都還很遠;2000 是留給「一個沒有 .gitignore 的大筆記庫」的餘裕。
|
||
// 一棵樹在收端是一把 KV、整棵覆寫,2000 個節點約 400 KB,離 KV 的 25 MB 很遠。
|
||
MaxFolderTreeNodes = 2000
|
||
|
||
// folderTreeRetryDelay:整棵樹送失敗後,同一份內容多久才准再試。
|
||
// 理由同 inventory.go:積壓時每輪都有事件,沒有退避就是每 5 秒撞一次(t195 教訓)。
|
||
folderTreeRetryDelay = 10 * time.Minute
|
||
|
||
// folderTreeMinInterval:**成功送出**之後,同一個資料夾至少隔多久才准再送。
|
||
//
|
||
// 🔴 為什麼需要這道閘(而總覽卡不需要):總覽卡多了一條「本輪無事件就不送」,
|
||
// 而這棵樹刻意沒有那一條(空資料夾從頭到尾沒有事件,見下面 syncFolderTree 的說明)。
|
||
// 少了那道閘,初次同步時的行為是:每收完一個檔「已同步數」就變一次
|
||
// ⇒ 內容雜湊每輪都不同 ⇒ **每 5 秒送一整棵樹**。
|
||
// 收端一次回報=一次 KV 寫入,而 Workers KV 免費層是 1000 writes/day
|
||
// ⇒ 沒有這道閘,一個資料夾的初次同步就能在一小時內把當天額度燒光,
|
||
// 而額度燒光的症狀是「畫面停在某個數字不動」——看起來像壞掉,不像額度。
|
||
//
|
||
// 2 分鐘是「人盯著畫面等進度」與「一天的額度」之間的折衷:
|
||
// 最壞 30 次/小時 × 24 = 720 次/天/資料夾,靜止時是 0(雜湊閘擋住)。
|
||
// **首次送出不受此限**(m.FolderTreeHash == "")——指定資料夾的當下就要看得見它,
|
||
// 那正是 arcrun-rag#106 的重點。
|
||
folderTreeMinInterval = 2 * time.Minute
|
||
)
|
||
|
||
// FolderNode=樹上的一個節點(=地端的一個資料夾)。
|
||
//
|
||
// 🔴 數字全部是**這一層直接放的檔案**(不含子資料夾)。子樹的合計由畫面自己疊——
|
||
// 存兩套(直接數+累計數)就是同一件事兩份實作,遲早對不起來。
|
||
type FolderNode struct {
|
||
// Path=相對監看根的路徑,`""` 代表根自己。一律 `/` 分隔(跨平台一致)。
|
||
Path string `json:"path"`
|
||
// Name=這一層的名字(根用資料夾 basename,人看得懂「這是哪個資料夾」)。
|
||
Name string `json:"name"`
|
||
// Parent=上一層的 Path;根自己是 `"-"`(空字串已經被根佔走,要分得出「我是根」)。
|
||
Parent string `json:"parent"`
|
||
Depth int `json:"depth"`
|
||
|
||
// TotalFiles=這一層看得到的檔案總數(**分母**)——含不支援的、含被策略排除的。
|
||
// 這就是使用者在 Finder 裡數得出來的那個數字(隱藏檔除外,他也看不到)。
|
||
TotalFiles int `json:"total_files"`
|
||
// SyncedFiles=已經送進雲端知識庫、而且送上去之後內容沒再變過的(**分子**)。
|
||
SyncedFiles int `json:"synced_files"`
|
||
// PendingFiles=認得、還沒送完(排隊中/退避中/已放棄自動重試)。
|
||
PendingFiles int `json:"pending_files"`
|
||
// UnsupportedFiles=副檔名我們還讀不了的(leo 講的「不支援的格式」)。
|
||
UnsupportedFiles int `json:"unsupported_files"`
|
||
// ExcludedFiles=收檔策略決定不收的(leo 講的「程式碼」多半落在這裡,見 ingestplan.go)。
|
||
ExcludedFiles int `json:"excluded_files"`
|
||
|
||
// Skipped=**這一層的檔案這次一個都不收**。SkipReason 說得出為什麼。
|
||
//
|
||
// 🔴 2026-08-28(inkstone/Arcrun#180)語意收窄了一格,因為 leo 推翻了原本的做法:
|
||
// 以前 Skipped 同時代表「整棵沒走進去」⇒ 數字全是 0 而且不是事實 ⇒ **底下一個
|
||
// 子節點都不會生**。leo 的原話:「**所有的 system-dev 都可以展開,因為就算沒有
|
||
// 可萃的它也有下層**」「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現」。
|
||
// ⇒ 現在絕大多數 Skipped 節點是**走進去了、數字是真的、子節點也都在**,
|
||
// 只是這一層的檔不收(範本檔、非本次收檔範圍…)。
|
||
//
|
||
// 仍然有數字是 0 而且不是事實的那一種——真的沒走進去的(`node_modules` 這類,
|
||
// 見 IngestPlan.SkipsDirWhy 剩下的五條)。**分辨方法:那種節點沒有子節點。**
|
||
// 畫面看到 Skipped 一律改講 SkipReason,不要只顯示分子分母。
|
||
Skipped bool `json:"skipped,omitempty"`
|
||
// SkipReason=一句話講給使用者聽的「為什麼整個沒收」(原文來自 IngestPlan.SkipsDirWhy)。
|
||
SkipReason string `json:"skip_reason,omitempty"`
|
||
}
|
||
|
||
// FolderTree=一個監看根的整棵樹,加上「為什麼只收這些」的那句話。
|
||
type FolderTree struct {
|
||
Library string `json:"library"` // 歸庫鍵(cfg.libraryFor(absRoot),每根唯一)
|
||
DisplayName string `json:"display_name"` // 資料夾 basename,給人看的
|
||
Root string `json:"root"` // 地端絕對路徑(navigator:回得去地端)
|
||
Mode string `json:"mode"` // 收檔策略(all/curated-wiki/docs-only)
|
||
Reason string `json:"reason"` // 那句人話(IngestPlan.Reason)
|
||
Nodes []FolderNode `json:"nodes"`
|
||
Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來
|
||
TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數
|
||
GeneratedAt int64 `json:"generated_at"`
|
||
}
|
||
|
||
// BuildFolderTree 把「走訪時數出來的分母」與「manifest 現況的分子」合成一棵樹。
|
||
//
|
||
// 純函式、零 IO、輸出確定(同一份輸入永遠同一份輸出)——冪等判斷(內容雜湊)與測試
|
||
// 都靠這一點,與 BuildInventoryCard 同一套慣例。
|
||
//
|
||
// dirs=Scan() 走訪時逐目錄數出來的分母(見 scan.go dirStat)。
|
||
// entries=Scan() 之後的 manifest 現況(分子的唯一來源)。
|
||
// excludedDirs=整棵被剪掉的目錄與理由(Scan() 記的那份,**未裁切的全量**)。
|
||
func BuildFolderTree(absRoot, library string, dirs map[string]*dirStat, entries map[string]*ManifestEntry, excludedDirs []ExcludedDir, plan IngestPlan, now time.Time) FolderTree {
|
||
nodes := map[string]*FolderNode{}
|
||
|
||
// 遞迴閉包(補祖先鏈要呼叫自己)⇒ 必須先宣告再賦值。
|
||
var ensure func(rel string) *FolderNode
|
||
ensure = func(rel string) *FolderNode {
|
||
if n, ok := nodes[rel]; ok {
|
||
return n
|
||
}
|
||
n := &FolderNode{Path: rel, Name: folderNodeName(absRoot, rel), Parent: folderNodeParent(rel), Depth: folderNodeDepth(rel)}
|
||
nodes[rel] = n
|
||
// 中間層可能沒被 dirStat 記到(例如只有被剪掉的子目錄),補齊祖先鏈——
|
||
// 少一層,畫面就接不成一棵樹(會變成一堆浮著的節點)。
|
||
if rel != "" {
|
||
ensure(n.Parent)
|
||
}
|
||
return n
|
||
}
|
||
ensure("") // 根一定存在,就算資料夾是空的(arcrun-rag#106 的整個重點)
|
||
|
||
for rel, st := range dirs {
|
||
if st == nil {
|
||
continue
|
||
}
|
||
n := ensure(rel)
|
||
n.TotalFiles = st.total
|
||
n.UnsupportedFiles = st.unsupported
|
||
n.ExcludedFiles = st.excluded
|
||
// #180:這一層走進去了、數字是真的,但**一個檔都沒收** ⇒ 照樣要講得出為什麼。
|
||
// 以前這種節點根本不存在(整棵被剪掉),現在存在了,就不能只給一個沒有解釋的
|
||
// `0 / 6`——那對使用者跟「安靜地少收」是同一件事。
|
||
// 🔴 子節點與真實數字都留著(leo 2026-08-28:「跳過的節點仍要展得開」)。
|
||
if st.total > 0 && st.excluded == st.total && st.excludeWhy != "" {
|
||
n.Skipped = true
|
||
n.SkipReason = st.excludeWhy
|
||
}
|
||
}
|
||
|
||
// 分子:manifest 現況。已送達且送上去之後沒再改過=已同步,其餘=還在路上。
|
||
// 判準與 progress.go 的 Done 完全一致(同一句 `IngestedHash == ContentHash`),
|
||
// 不另立第二把尺。
|
||
for rel, e := range entries {
|
||
if e == nil {
|
||
continue
|
||
}
|
||
n := ensure(folderOfRel(rel))
|
||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||
n.SyncedFiles++
|
||
} else {
|
||
n.PendingFiles++
|
||
}
|
||
}
|
||
|
||
// 整棵被剪掉的目錄:列出來、講理由,數字留 0 但標 Skipped
|
||
//(沒走進去就是不知道裡面有幾個檔——寫 0 當事實就是我們在編數字)。
|
||
for _, ed := range excludedDirs {
|
||
n := ensure(ed.Path)
|
||
n.Skipped = true
|
||
n.SkipReason = ed.Reason
|
||
}
|
||
|
||
out := make([]FolderNode, 0, len(nodes))
|
||
for _, n := range nodes {
|
||
out = append(out, *n)
|
||
}
|
||
// 排序=畫面每輪穩定(map 迭代順序隨機)。根永遠第一,其餘按路徑字母序。
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if (out[i].Path == "") != (out[j].Path == "") {
|
||
return out[i].Path == ""
|
||
}
|
||
return out[i].Path < out[j].Path
|
||
})
|
||
|
||
total := len(out)
|
||
truncated := false
|
||
if total > MaxFolderTreeNodes {
|
||
out = out[:MaxFolderTreeNodes]
|
||
truncated = true
|
||
}
|
||
|
||
return FolderTree{
|
||
Library: library,
|
||
DisplayName: folderNodeName(absRoot, ""),
|
||
Root: absRoot,
|
||
Mode: string(plan.Mode),
|
||
Reason: plan.Reason,
|
||
Nodes: out,
|
||
Truncated: truncated,
|
||
TotalNodes: total,
|
||
GeneratedAt: now.Unix(),
|
||
}
|
||
}
|
||
|
||
// folderOfRel 回傳某個檔案相對路徑所屬的目錄(根層檔案回 `""`)。
|
||
func folderOfRel(rel string) string {
|
||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||
if i := strings.LastIndexByte(rel, '/'); i > 0 {
|
||
return rel[:i]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func folderNodeParent(rel string) string {
|
||
if rel == "" {
|
||
return "-" // 根:刻意不是空字串,不然分不出「我是根」與「我的父親是根」
|
||
}
|
||
return folderOfRel(rel)
|
||
}
|
||
|
||
func folderNodeDepth(rel string) int {
|
||
if rel == "" {
|
||
return 0
|
||
}
|
||
return strings.Count(rel, "/") + 1
|
||
}
|
||
|
||
// folderNodeName 回傳節點顯示名。根用監看資料夾自己的 basename——
|
||
// 使用者認得的是「我那個資料夾叫什麼」,不是一個空字串。
|
||
func folderNodeName(absRoot, rel string) string {
|
||
if rel != "" {
|
||
return path.Base(rel)
|
||
}
|
||
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
|
||
if base == "" || base == "." || base == "/" {
|
||
return "知識資料夾"
|
||
}
|
||
return base
|
||
}
|
||
|
||
// Hash 回傳整棵樹的內容雜湊(不含 GeneratedAt——時間每輪都變,含進去就等於沒有冪等)。
|
||
func (t FolderTree) Hash() string {
|
||
c := t
|
||
c.GeneratedAt = 0
|
||
data, err := json.Marshal(c)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
return "sha256:" + hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// syncFolderTree 每輪掃描後把樹送上雲端。回 nil=這輪不必送。
|
||
//
|
||
// 冪等/防撞四層(前三層同 inventory.go 的慣例,刻意不另立一套):
|
||
//
|
||
// ①整棵樹的內容雜湊記在 manifest.folder_tree_hash,沒變不送;
|
||
// ②同一份內容剛失敗過 → 退避窗口內不重撞;
|
||
// ③**成功送過之後有最小間隔**(folderTreeMinInterval,理由見該常數);
|
||
// ④收端整棵覆寫(同一個 library 一把 KV),重送不堆副本、也不會留下半棵樹。
|
||
//
|
||
// 🔴 與 syncInventory 的差別:**沒有「本輪無事件就不送」那一條**。
|
||
// 空資料夾從頭到尾都沒有事件(arcrun-rag#106 的情境本身),漏掉這一送,
|
||
// 「指定了資料夾雲端就看得到」這件事永遠不會發生。
|
||
// ——但正因為拿掉了那道閘,才必須補上第③層,否則初次同步會變成每 5 秒送一次。
|
||
//
|
||
// 🔴 **整棵樹一次送**(本檔早先的版本切成每批 20 個節點,已拿掉):
|
||
// 分批的唯一理由是「收端每個節點一次 KBDB 寫入=一個 subrequest」,而收端
|
||
// (`cypher-executor` 的 `/portal/daemon/folder-tree`)把樹存成一把 KV、整棵覆寫
|
||
// ⇒ 一次回報只有一次寫入。分批在那個設計下只剩壞處:中途失敗留下半棵樹,
|
||
// 而且要另一套 prune 邏輯去清「這次沒報上來的節點」。
|
||
//
|
||
// 失敗不擋同步主流程(不設 exit):樹是畫面層,它壞了檔案同步照常走;
|
||
// 但誠實回報 failed,不假綠。
|
||
func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderTree, dryRun bool, now time.Time) *DirectResult {
|
||
h := tree.Hash()
|
||
if h != "" && h == m.FolderTreeHash {
|
||
return nil // ①內容沒變
|
||
}
|
||
if h != "" && h == m.FolderTreeFailHash && now.Unix() < m.FolderTreeNextRetry {
|
||
return nil // ②同一份內容剛失敗過,退避窗口內不重撞
|
||
}
|
||
// ③首次送出不受最小間隔限制(指定資料夾的當下就要看得見它);之後才節流。
|
||
if m.FolderTreeHash != "" && now.Unix() < m.FolderTreeNextSend {
|
||
return nil
|
||
}
|
||
res := &DirectResult{Type: "folder_tree", Path: absRoot}
|
||
if dryRun {
|
||
res.Status = "planned"
|
||
return res
|
||
}
|
||
|
||
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
|
||
// Nodes 至少有根一個(BuildFolderTree 的 ensure("")),空資料夾照送——
|
||
// 收端據此把庫登記起來,這正是 arcrun-rag#106 的解。
|
||
body := map[string]any{
|
||
"library": tree.Library,
|
||
"display_name": tree.DisplayName,
|
||
"root": tree.Root,
|
||
"mode": tree.Mode,
|
||
"reason": tree.Reason,
|
||
"truncated": tree.Truncated,
|
||
"total_nodes": tree.TotalNodes,
|
||
"generated_at": tree.GeneratedAt,
|
||
"sync_token": h,
|
||
"nodes": tree.Nodes,
|
||
}
|
||
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
|
||
res.HTTPStatus = status
|
||
if err != nil {
|
||
res.Status = "failed"
|
||
// 🔴 **這句話會出現在使用者的畫面上**(status.json → 托盤),所以不准裸露
|
||
// 錯誤碼/HTTP 狀態/上游 JSON 原文(同 direct_quota_test.go 那份禁字表:
|
||
// 「4006」「502」「HTTP」「neurons」)。技術細節不必寫進這句——
|
||
// `res.HTTPStatus` 已經把狀態碼結構化地留著了,要查的人查得到。
|
||
//
|
||
// 📌 這是本分支自己踩到的:原本寫成 `… + err.Error()`,於是額度耗盡那天
|
||
// 使用者看到的會是 `HTTP 502:{"error":"4006: you have used up your daily
|
||
// free allocation of 10,000 neurons"}`——技術上正確,人話上是零。
|
||
// 分類走 inventory.go 早就在用的 `isQuotaExhausted`(InkStoneCo#43 立的那支),
|
||
// 不另造第二套判斷。
|
||
if isQuotaExhausted(err.Error()) {
|
||
res.Error = "雲端今天的額度用完了,資料夾結構會稍後自動補送(不影響檔案同步)"
|
||
} else {
|
||
res.Error = "資料夾結構這次沒送上去,稍後會自動再試(不影響檔案同步)"
|
||
}
|
||
m.FolderTreeFailHash = h
|
||
m.FolderTreeNextRetry = now.Add(folderTreeRetryDelay).Unix()
|
||
return res
|
||
}
|
||
res.Status = "ingested"
|
||
m.FolderTreeHash = h
|
||
m.FolderTreeFailHash = ""
|
||
m.FolderTreeNextRetry = 0
|
||
m.FolderTreeNextSend = now.Add(folderTreeMinInterval).Unix()
|
||
return res
|
||
}
|
||
|
||
// ── 本機那一份:桌面小幫手要攤開的,是同一棵樹 ─────────────────────────────
|
||
//
|
||
// 🔴 為什麼要在本機也落地一份(`inkstone/InkStoneCo#44`,leo 2026-08-26):
|
||
//
|
||
// leo 的交付定義第一段是「**在 Portal 和桌面小幫手上**,任何一個連上的資料夾
|
||
// 都攤得開它完整的巢狀子資料夾樹」。上面的 syncFolderTree 只完成了前半——
|
||
// 樹送上雲端、由 portal 讀回來畫。**桌面小幫手不能走那條路**,三個理由:
|
||
//
|
||
// ① 它要在**離線**、雲端額度用完、或 syncFolderTree 正在退避窗口裡的時候
|
||
// 照樣攤得開——那些正是使用者最想知道「到底同步到哪了」的時刻;
|
||
// ② 樹是**本機算出來的**(分母來自這台電腦的 WalkDir),繞一趟雲端再拿回來,
|
||
// 等於讓本機畫面依賴一條它根本不需要的網路;
|
||
// ③ syncFolderTree 有內容雜湊閘與最小間隔,**它回 nil 的輪次遠多於送出的輪次**
|
||
// ——本機畫面不該跟著那道為了省 KV 額度而設的閘一起沉默。
|
||
//
|
||
// 🔴 **不另算第二套**(本票紅線):這裡存下來的就是 BuildFolderTree 交出、
|
||
// 也正要送上雲端的那個 `FolderTree`,一個欄位都不重組、一個數字都不重算。
|
||
// 桌面與 portal 看到的若有一天不一樣,那只可能是「哪一邊比較舊」,
|
||
// 不可能是「兩邊各自算」。
|
||
|
||
// FolderTreeStore=本機的樹快照(key=監看根的絕對路徑)。
|
||
//
|
||
// 為什麼是「一個檔裝全部」而不是每個根一個檔:桌面小幫手拿得到的是
|
||
// config 裡那串資料夾路徑,用路徑當 key 直接查得到;每根一檔就得讓 App 去
|
||
// 複製 manifestPathFor 那條 host+路徑的雜湊公式——**那才是第二份實作**。
|
||
type FolderTreeStore struct {
|
||
UpdatedAt string `json:"updated_at"`
|
||
Trees map[string]FolderTree `json:"trees"`
|
||
}
|
||
|
||
// FolderTreeStorePath 回傳本機樹快照的路徑:與 manifest/status.json 同目錄。
|
||
func FolderTreeStorePath(manifestPath string) string {
|
||
return filepath.Join(filepath.Dir(manifestPath), "folder-trees.json")
|
||
}
|
||
|
||
// LoadFolderTreeStore 讀回上一輪的快照;讀不到/解析失敗都回可用的零值
|
||
// (呼叫端當「沒有上一輪」處理,與 LoadSyncStatus 同一套慣例)。
|
||
func LoadFolderTreeStore(path string) (FolderTreeStore, error) {
|
||
s := FolderTreeStore{Trees: map[string]FolderTree{}}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return s, err
|
||
}
|
||
if err := json.Unmarshal(data, &s); err != nil {
|
||
return FolderTreeStore{Trees: map[string]FolderTree{}}, err
|
||
}
|
||
if s.Trees == nil {
|
||
s.Trees = map[string]FolderTree{}
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// SaveFolderTreeStore 覆寫快照。
|
||
func SaveFolderTreeStore(path string, s FolderTreeStore) error {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return err
|
||
}
|
||
data, err := json.MarshalIndent(s, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(path, data, 0o644)
|
||
}
|
||
|
||
// MergeFolderTreeStore 把「這一輪算出來的樹」併進上一輪的快照。
|
||
//
|
||
// 三條規則,每一條都對應一個會讓畫面說謊的情境:
|
||
//
|
||
// ① 這一輪算出來的**覆蓋**舊的——樹永遠是現況,不累積歷史。
|
||
// ② 這一輪**沒算出來**(那個根掃描失敗、或還沒輪到)⇒ 沿用上一輪的。
|
||
// 不然使用者會看到自己昨天還好好的資料夾突然變成「還沒回報」——
|
||
// 那是 2026-08-05「明明做完了畫面卻說等待中」同一個形狀的病。
|
||
// ③ 已經**不在看守清單**上的根⇒刪掉。留著就是一棵指向不存在設定的幽靈樹,
|
||
// 而且使用者移除資料夾之後還看得到它,會以為沒移掉。
|
||
//
|
||
// knownRoots=這一輪 RunDirectOnce 打算處理的所有監看根(不論成功與否)。
|
||
func MergeFolderTreeStore(prev FolderTreeStore, fresh map[string]FolderTree, knownRoots []string, now time.Time) FolderTreeStore {
|
||
known := make(map[string]bool, len(knownRoots))
|
||
for _, r := range knownRoots {
|
||
known[r] = true
|
||
}
|
||
out := FolderTreeStore{UpdatedAt: now.UTC().Format(time.RFC3339), Trees: map[string]FolderTree{}}
|
||
for root, t := range prev.Trees {
|
||
if known[root] { // ②③:還在看守清單上才留,其餘丟掉
|
||
out.Trees[root] = t
|
||
}
|
||
}
|
||
for root, t := range fresh {
|
||
out.Trees[root] = t // ①
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PublishFolderTreeNow 把**這一個根**剛算好的樹立刻寫進本機快照,不等整輪跑完。
|
||
//
|
||
// 🔴 為什麼要有它(`inkstone/arcrun-rag#153` 第二輪,2026-08-28 實測):
|
||
// 一輪同步是**一條線**走完的,而對一個健康的雲端實例,每一發呼叫實測要 24〜33 秒
|
||
// (`ARCRUN_TRACE=1` 量的:資料夾總覽 26.3s、目錄索引 24.0/24.1/23.5s、
|
||
// 送出一份筆記 33.2/44.6s)。一個 12 層、10 個檔的資料夾,一輪就是**十幾分鐘**;
|
||
// leo 真正的設定是三個帳號、好幾千個檔,一輪是**好幾小時**。
|
||
//
|
||
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地
|
||
// ⇒ 使用者看到的是「小幫手開著、沒有錯誤、什麼都不動,資料夾結構永遠停在上一版」。
|
||
// **那不是卡住,是這一輪還沒輪到寫它。**
|
||
//
|
||
// 樹本身是**純本機、秒級**算出來的(BuildFolderTree 在掃描一結束就有答案),
|
||
// 它沒有任何理由要排在十幾分鐘的雲端佇列後面等。
|
||
//
|
||
// 與收工時那次 SaveFolderTreeStore 的分工:
|
||
// - 這裡:只**更新這一個根**,不刪任何東西(還不知道整輪看守哪些根)。
|
||
// - 收工:跑完整的 MergeFolderTreeStore,該刪的(已不看守的根)在那時才刪。
|
||
// 兩次都是冪等的覆蓋,先寫一次不會讓收工那次結果不同。
|
||
//
|
||
// 失敗一律吞掉:快照是給畫面看的,寫不進去不該擋住同步本體。
|
||
func PublishFolderTreeNow(manifestPath, root string, tree FolderTree, now time.Time) {
|
||
if manifestPath == "" || root == "" || len(tree.Nodes) == 0 {
|
||
return // 沒算出東西就別覆蓋上一輪的好資料(同 MergeFolderTreeStore 規則②)
|
||
}
|
||
path := FolderTreeStorePath(manifestPath)
|
||
store, _ := LoadFolderTreeStore(path) // 讀不到=沒有上一輪,零值可用
|
||
if store.Trees == nil {
|
||
store.Trees = map[string]FolderTree{}
|
||
}
|
||
store.Trees[root] = tree
|
||
store.UpdatedAt = now.UTC().Format(time.RFC3339)
|
||
_ = SaveFolderTreeStore(path, store)
|
||
}
|