Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d47d9080 | |||
| e3f1d0177d | |||
| 4d966b7922 | |||
| a1786135d5 |
+19
-1
@@ -47,7 +47,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 下一版(未發佈)
|
||||
## 0.18.39(2026-08-27)
|
||||
|
||||
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
|
||||
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
|
||||
|
||||
## 0.18.38(2026-08-26)
|
||||
|
||||
- 🔴 **每一層子資料夾都看得到,不是只有第一層**:以前你只看得到自己選的那個資料夾本身,裡面到底有哪些子資料夾、哪幾層、哪些東西真的上去了,完全看不到。現在**桌面小幫手與知識庫網頁兩邊都攤得開**:小幫手裡點進那個知識庫,資料夾右邊多一顆「資料夾結構」,按下去就是一棵像檔案總管那樣、一列一列往下縮排的樹,點資料夾展開、再點收合,也可以一鍵全部展開/全部收合。
|
||||
- **小幫手看得到,不必等雲端、也不必連線**:這棵樹是你這台電腦自己算出來的。就算網路斷了、或今天的 AI 額度用完了,它照樣打得開——那正是你最想知道「到底同步到哪了」的時候。
|
||||
- **每一層都告訴你「幾份、上去了幾份」**:每個資料夾旁邊會標「已同步 / 總共」。兩個數字不一樣也會告訴你差在哪裡——還讀不了的格式、不在收檔範圍的檔案,還是還在處理中。
|
||||
- **整個沒走進去的資料夾,不會假裝成 0 / 0**:被跳過的資料夾會直接寫「整個資料夾未收」加上原因,而不是給你一個我們自己編的數字。
|
||||
|
||||
## 0.18.37(2026-08-26)
|
||||
|
||||
- 🔴 **雲端被清空過的檔案,小幫手現在會自己發現並補送**:以前小幫手送成功一次就會在本機蓋一個「已送」的章,而那個章**永遠不會過期**。萬一雲端被重裝或清空,那批檔案就**再也不會被送上去**,而且沒有任何地方會跟你說——你只會發現「檔案明明在資料夾裡,AI 卻查不到」,而且查不出為什麼。現在它會跟雲端對一次帳,發現那邊沒有就重新送。
|
||||
- **重跑第二次不會又全部送一遍**:對完帳之後它分得出「雲端真的沒有」與「我上次送過了」,不會把你的 AI 額度燒掉。
|
||||
- **補送的時候看得到**:不會靜悄悄跑完。
|
||||
|
||||
## 0.18.36(2026-08-24)
|
||||
|
||||
- 🔴 **移除資料夾時,可以順便把我們放進去的隱藏檔案一起清掉**:小幫手整理你的資料夾時,會在**每一層**放一個隱藏的整理稿目錄(`.wiki`/`.arcrun-rag`)。以前你把資料夾從清單移除之後,這些東西**全部留在原地**——隱藏的、散在幾十層裡,你自己幾乎刪不乾淨。現在移除的對話框多了一個勾選框「順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉」,勾起來會**先把確切要刪哪些列給你看**,你看過再按確定。
|
||||
- **只刪我們自己建的,你的檔案一個都不會碰**:每一筆都要有我們自己寫下的依據(我們放的說明檔、我們的整理帳本、我們的檔名前綴)才會刪;只要有一個東西認不出來,那整個目錄就原封不動留著,並且在清單上告訴你留了什麼、為什麼留。
|
||||
|
||||
+284
@@ -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://<path>#<第幾塊>`,
|
||||
// 而第 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)
|
||||
}
|
||||
}
|
||||
@@ -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("<html>cloudflare</html>"))
|
||||
},
|
||||
"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("沒有連線資訊時不該宣稱查得到答案")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.35",
|
||||
"fingerprint": "3bd7f3fb0124bda4",
|
||||
"version": "0.18.39",
|
||||
"fingerprint": "316426f50e886003",
|
||||
"files": {
|
||||
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
|
||||
"CHANGELOG.md": "1c740bc8fa983be721c80c95b7941504d3d5e42c6e8d663a59d9c294d72e5877",
|
||||
"CHANGELOG.md": "f6f3858cd587278528f68ff7290722b6e67fcd763a5b159791169c49387d5502",
|
||||
"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": "274129ed77c4f4194f6b2007cb47e4ffe536b15b6450fe89b14fbb31e6a16395",
|
||||
"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,8 +88,8 @@
|
||||
"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/style.css": "24b3797c731720b60ce8a2a19582b4a76e8e37a9d2eacce8dbe205ba4008849b",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "277390ab8bf4114c797aa32b2cdae151e86a63681c612c424e73c308c446234e",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2",
|
||||
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
|
||||
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
|
||||
"cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256",
|
||||
@@ -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,11 +122,11 @@
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
|
||||
"direct.go": "98d51b233e31320de20f4c605ce420433fb4720136c9eb188fbd69e1a136fb69",
|
||||
"direct_extract_test.go": "d3465c1b5953858ae780d192a2cf515d452f48eee3ff18a6345ce64320debbbb",
|
||||
"direct_multi_test.go": "d0aa211d90aaaa9d7dbe2b04f1aaa5989e91e91b037144cc0f4e0fd068f43c2b",
|
||||
"direct.go": "8f5adcbfe51e6b21b4cb585fd02c7a2f66ffa28770b61aa9722abd424c35c8a3",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3",
|
||||
"direct_pacing_test.go": "c21298922866eb9dabc6a87d9d466e08e1001db52841c86f3e720dacfe955bd8",
|
||||
"direct_pacing_test.go": "ba5c140082931a50c3728078502803c654c2fb9dacecda679f8f40e6ab61a0c5",
|
||||
"direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080",
|
||||
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
|
||||
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
|
||||
@@ -128,12 +135,15 @@
|
||||
"extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2",
|
||||
"extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8",
|
||||
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
|
||||
"extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3",
|
||||
"extract_workersai.go": "d8f192472883e7a7f5d034d9b0e0d8be46f284bef2c500f934ec740379efffde",
|
||||
"extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e",
|
||||
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
|
||||
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
|
||||
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
|
||||
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
|
||||
"foldertree.go": "8b3de4fde32e538b3a6a9440f6fb89e5737391a2499668b961a933509b36fa17",
|
||||
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
|
||||
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
|
||||
"go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db",
|
||||
"go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3",
|
||||
"grounding.go": "7152771ab1bb152946209f8e94e52bdf2facc6192b7a8ca018e37fed4557e9c0",
|
||||
@@ -144,14 +154,14 @@
|
||||
"ingestplan_test.go": "1ad31f4fb69d917e3f9d46f5615f38074b7ed6ec8baa01d71bd1600d317b7878",
|
||||
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
|
||||
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
|
||||
"inventory_test.go": "1821c81cf2b9e20fb82a79313686393391a6f46185dfa4a4a4c2bfb4e0e7c170",
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
|
||||
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
|
||||
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
|
||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
|
||||
"main.go": "e02e7b32dd230f33e35c7ebf62d81b7599e2b86a0ef7cadc0fd34b672f42381d",
|
||||
"manifest.go": "768049a34c06920711a980bb19fb41e7f1feff648f22265ba0c3d1c1ad85020f",
|
||||
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
||||
"manifest.go": "61a9403a3da46f270ce6a1610b9307fd19b794521b71a7a8f22cea36a6ea35cd",
|
||||
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||
@@ -166,7 +176,7 @@
|
||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
||||
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
||||
"scan.go": "23993b1411af0a0308aa6614e514d8c60bcbeed6ed93087b9c5bcc2e0f4763e9",
|
||||
"scan.go": "d26b74e46204697a573ed24ee427c06878349611fcaf3c35df295b826b690525",
|
||||
"scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575",
|
||||
"scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362",
|
||||
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
|
||||
@@ -176,7 +186,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",
|
||||
@@ -230,6 +240,8 @@
|
||||
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
|
||||
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
|
||||
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
|
||||
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
|
||||
"triggeroutcome_test.go": "fc429ea5da193e6250b10f7d87690c293c21a0b4db763eedbc04fac9e1995e96",
|
||||
"upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb",
|
||||
"upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91",
|
||||
"upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368",
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"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",
|
||||
"0.18.38": "7add5da65c671548",
|
||||
"0.18.39": "316426f50e886003"
|
||||
}
|
||||
|
||||
+65
-1
@@ -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)
|
||||
}
|
||||
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
|
||||
// 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。
|
||||
@@ -914,6 +927,57 @@ func (a *App) OpenLogFolder() error {
|
||||
// LogFolderPath 給畫面顯示用(讓使用者就算按鈕失效也知道去哪找)。
|
||||
func (a *App) LogFolderPath() string { return appDir() }
|
||||
|
||||
// ── 資料夾結構(`inkstone/InkStoneCo#44`,leo 2026-08-26)──
|
||||
//
|
||||
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
|
||||
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
|
||||
// 沒同步的那幾份為什麼沒上去。」——這支是桌面那半的資料入口。
|
||||
//
|
||||
// 🔴 **三件刻意不做的事**,每一件都是紅線:
|
||||
//
|
||||
// ① 不自己走一次檔案系統。樹是 collector 每輪算好落地的(BuildFolderTree ⇒
|
||||
// folder-trees.json),這裡只讀。App 若自己數一次,就會出現「畫面說 A、
|
||||
// 雲端說 B」——同一個資料夾兩份實作,遲早對不起來。
|
||||
// ② 不去雲端拿。樹本來就是本機算的;繞一趟雲端只是把離線、額度用完、
|
||||
// 退避窗口這幾種情況都變成「畫面壞掉」。
|
||||
// ③ 不掛在 GetState 上。GetState 每秒被 tick 呼叫一次,而樹上限 300 個節點;
|
||||
// 只有使用者真的按開那個資料夾時才需要它 ⇒ 獨立一支、按需呼叫。
|
||||
//
|
||||
// 回傳 nil 代表「這個資料夾還沒被回報過」(剛加入、還沒跑完第一輪),
|
||||
// **不是**空樹——畫面要分得出「還沒掃到」與「掃過但裡面是空的」(arcrun-rag#106
|
||||
// 那條紅線的同一個形狀:不准拿一個我們自己編的 0 去回答使用者)。
|
||||
func (a *App) GetFolderTree(path string) (*collector.FolderTree, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 快照與 manifest 同目錄。manifest 路徑以設定檔為準(使用者可能把它指到別處),
|
||||
// 讀不到設定就退回預設位置——與 statusPath() 同一個假設。
|
||||
store := filepath.Join(appDir(), "folder-trees.json")
|
||||
if cfg, err := loadCfg(); err == nil && strings.TrimSpace(cfg.Manifest) != "" {
|
||||
store = collector.FolderTreeStorePath(cfg.Manifest)
|
||||
}
|
||||
s, err := collector.LoadFolderTreeStore(store)
|
||||
if err != nil {
|
||||
// 檔案還不存在=第一輪還沒跑完,那不是錯誤,是「還沒有」。
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := s.Trees[path]; ok {
|
||||
return &t, nil
|
||||
}
|
||||
// 設定裡存的路徑與 collector 寫入時用的 absRoot 可能一個帶了尾斜線、
|
||||
// 一個沒有(AddFolder 直接存系統選擇器給的字串)⇒ 正規化後再找一次,
|
||||
// 不要因為一個斜線就跟使用者說「還沒回報」。
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
if t, ok := s.Trees[abs]; ok {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── 托盤會呼叫的兩個動作(t194)──
|
||||
|
||||
// ShowWindow 把主視窗叫出來並帶到前景。
|
||||
|
||||
@@ -296,6 +296,217 @@ function cardSkipped(k) {
|
||||
}
|
||||
|
||||
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半,leo 2026-08-26)
|
||||
//
|
||||
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
|
||||
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
|
||||
// 沒同步的那幾份為什麼沒上去。」Portal 那半已經在跑,這裡是桌面這半。
|
||||
//
|
||||
// 🔴 形狀規格=`inkstone/Arcrun#144`(leo 2026-08-19 驗 1.4.49 之後打回 markmap):
|
||||
// 「我的需求是**向右向下**,類似 terminal 的 tree,**一列一列向下往後退縮**,
|
||||
// 緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**,這是一般辦公室用戶
|
||||
// 都有的經驗,且可以用滑鼠輕易操作,不會佔用大面積。」
|
||||
// ⇒ 一列一行、縮排、可摺疊、字級行距貼近作業系統的檔案總管。
|
||||
// ⇒ **不用任何樹狀圖套件**:這個形狀就是「縮排的清單」,一個 div 一列就到位;
|
||||
// 引一包 library 進來只會多一份要跟著 CIS 對齊的樣式來源。
|
||||
//
|
||||
// 🔴 **一個判準都不在這裡發明**(同 portal 那半的紅線):兩個數字與「為什麼沒收」
|
||||
// 的分類全部由 collector 算好(`collector/foldertree.go`,判準活在 scan.go 那趟
|
||||
// WalkDir)。這裡只做兩件事:把節點串成樹、把子樹的數字加起來。
|
||||
// 哪天想在這裡寫「.py 算不算支援」——停手,那是第二份實作。
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// data:path → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
|
||||
// open:path → 這個資料夾的樹展開了沒
|
||||
// nodes:path → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen)
|
||||
const treeState = { data: {}, open: {}, nodes: {} };
|
||||
|
||||
// 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。
|
||||
function treeBoxId(path) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < path.length; i++) h = (h * 31 + path.charCodeAt(i)) | 0;
|
||||
return 'ft' + (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
// 子樹合計:小幫手送的每個數字都只算「這一層直接放的檔」(存兩套遲早對不起來),
|
||||
// 所以要顯示「這個資料夾底下總共」就得在這裡疊一次。
|
||||
// 🔴 skipped 的節點整棵不計:沒走進去就是不知道裡面有幾個檔,加 0 會讓分母說謊。
|
||||
// (與 portal 的 rollupTree 同一套算法——兩邊看到的數字必須是同一個。)
|
||||
function rollupTree(nodes) {
|
||||
const byPath = {}, kids = {};
|
||||
nodes.forEach((n) => { byPath[n.path] = n; (kids[n.parent] = kids[n.parent] || []).push(n); });
|
||||
const sums = {};
|
||||
function walk(n) {
|
||||
const acc = { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
|
||||
if (!n.skipped) {
|
||||
acc.total = n.total_files; acc.synced = n.synced_files; acc.pending = n.pending_files;
|
||||
acc.unsupported = n.unsupported_files; acc.excluded = n.excluded_files;
|
||||
}
|
||||
(kids[n.path] || []).forEach((c) => {
|
||||
const x = walk(c);
|
||||
acc.total += x.total; acc.synced += x.synced; acc.pending += x.pending;
|
||||
acc.unsupported += x.unsupported; acc.excluded += x.excluded;
|
||||
});
|
||||
sums[n.path] = acc;
|
||||
return acc;
|
||||
}
|
||||
// 根=parent 是 '-'(collector/foldertree.go 刻意用它,才分得出「我是根」與「父親是根」)
|
||||
(kids['-'] || []).forEach(walk);
|
||||
// 孤兒節點(父親不在這份清單裡,例如樹被截斷)也要走一次,否則它整棵不會被算到
|
||||
nodes.forEach((n) => { if (sums[n.path] === undefined && !byPath[n.parent]) walk(n); });
|
||||
return { sums, kids, byPath };
|
||||
}
|
||||
|
||||
// 差額必須解釋得了:總數 − 已同步 = 不支援 + 不收 + 處理中。
|
||||
// 這行文案的存在理由就是 leo 那句「不上傳通常是不支援,比如程式碼、不支援的格式」
|
||||
// ——畫面要自己回答,不必問人。
|
||||
function gapWhy(s) {
|
||||
const parts = [];
|
||||
if (s.unsupported > 0) parts.push(s.unsupported + ' 份格式還讀不了');
|
||||
if (s.excluded > 0) parts.push(s.excluded + ' 份不在收檔範圍(程式碼等)');
|
||||
if (s.pending > 0) parts.push(s.pending + ' 份處理中');
|
||||
return parts.join('・');
|
||||
}
|
||||
|
||||
// 預設只展開根那一層——**照檔案總管的行為**:打開一個資料夾看到它底下一層,
|
||||
// 要更深自己點。一次全攤開在 8000 個檔的資料夾上就是一面沒人看得完的牆。
|
||||
function nodeIsOpen(path, n) {
|
||||
const st = treeState.nodes[path] || {};
|
||||
return st[n.path] === undefined ? n.depth === 0 : st[n.path];
|
||||
}
|
||||
|
||||
function renderFolderTree(path) {
|
||||
const box = $(treeBoxId(path));
|
||||
if (!box) return;
|
||||
const tree = treeState.data[path];
|
||||
if (tree === undefined) { box.innerHTML = `<div class="ftmsg">讀取中…</div>`; return; }
|
||||
// 🔴 分得出「還沒掃到」與「掃過、裡面是空的」——後者是 arcrun-rag#106 的正常情況
|
||||
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
|
||||
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
|
||||
if (tree === null) {
|
||||
box.innerHTML = `<div class="ftmsg">同步小幫手還沒掃到這個資料夾的結構。
|
||||
第一次同步跑完之後就會出現——檔案多的話要等久一點,按上面的「立刻同步」可以催它。</div>`;
|
||||
return;
|
||||
}
|
||||
const nodes = tree.nodes || [];
|
||||
if (!nodes.length) {
|
||||
box.innerHTML = `<div class="ftmsg">這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。</div>`;
|
||||
return;
|
||||
}
|
||||
const r = rollupTree(nodes);
|
||||
let html = '';
|
||||
function emit(n) {
|
||||
const s = r.sums[n.path] || { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
|
||||
const kids = (r.kids[n.path] || []).slice().sort((a, b) => (a.path < b.path ? -1 : 1));
|
||||
const open = kids.length ? nodeIsOpen(path, n) : false;
|
||||
// 縮排 16px 一層=檔案總管的量級;一列一行、往後退縮(Arcrun#144 的形狀)。
|
||||
const indent = 4 + n.depth * 16;
|
||||
// 🔴 三角形用 CSS 畫(`<i>` 那個空元素),**不用 ▸▾ 字元**:
|
||||
// 那兩個字在 Windows 的中文字型裡會被當成全形符號、大小與位置各機器不同,
|
||||
// 而它正是 leo 2026-08-19 點名「展開字很小、滑鼠不好按」的那個東西。
|
||||
// 畫出來的三角形每一台都一樣大,也才控得住點擊區。
|
||||
// 可展開的那幾列是**按鈕**,不是裝飾用的 div:標上 role/tabindex/aria-expanded
|
||||
// ⇒ 鍵盤按得到、輔助技術念得出「收合/展開」,機械檢查也看得見它是可操作的。
|
||||
let row = `<div class="ftrow${kids.length ? ' has' : ''}${open ? ' open' : ''}" style="padding-left:${indent}px"`
|
||||
+ (kids.length
|
||||
? ` data-tnode="${esc(n.path)}" data-troot="${esc(path)}" role="button" tabindex="0"`
|
||||
+ ` aria-expanded="${open}" title="${open ? '收合' : '展開'}「${esc(n.name || '')}」"`
|
||||
: '') + `>`
|
||||
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
|
||||
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
|
||||
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
|
||||
if (n.skipped) {
|
||||
// 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。
|
||||
const why = n.skip_reason || '(小幫手沒說明理由)';
|
||||
row += `<span class="skip">整個資料夾未收</span>`
|
||||
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
|
||||
} else {
|
||||
const full = s.total > 0 && s.synced === s.total;
|
||||
row += `<span class="num${full ? ' full' : ''}">${s.synced} / ${s.total}</span>`;
|
||||
const why = gapWhy(s);
|
||||
if (why) row += `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
|
||||
}
|
||||
if (kids.length && !open) row += `<span class="why">…還有 ${kids.length} 個子資料夾</span>`;
|
||||
html += row + `</div>`;
|
||||
if (open) kids.forEach(emit);
|
||||
}
|
||||
(r.kids['-'] || []).forEach(emit);
|
||||
// 父親不在清單裡的孤兒(樹被截斷時會有)——照樣列出來,缺角要看得見,不要偷偷藏起來
|
||||
const seen = {};
|
||||
nodes.forEach((n) => { seen[n.path] = true; });
|
||||
nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); });
|
||||
|
||||
let foot = '';
|
||||
if (tree.truncated) {
|
||||
foot += `<div class="ftmsg">資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。</div>`;
|
||||
}
|
||||
foot += `<div class="ftmsg">數字是「已同步 / 這個資料夾底下總共」。兩者不相等是正常的——
|
||||
差額是還讀不了的格式、不在收檔範圍的檔案(程式碼等),或還在處理中。</div>`;
|
||||
box.innerHTML = `<div class="fthead">`
|
||||
+ `<button class="ghost" data-texpand="${esc(path)}">全部展開</button>`
|
||||
+ `<button class="ghost" data-tcollapse="${esc(path)}">全部收合</button>`
|
||||
+ (tree.reason ? `<span class="why">${esc(tree.reason)}</span>` : '')
|
||||
+ `</div><div class="ftbody">${html}</div>${foot}`;
|
||||
wireTree();
|
||||
}
|
||||
|
||||
// 展開/收合某個資料夾的樹。第一次展開才去讀(同 portal 的 lazy 做法),
|
||||
// 之後留在記憶體裡——它是**畫面暫存不是第二份真相源**,關掉視窗就沒了。
|
||||
async function toggleFolderTree(path) {
|
||||
treeState.open[path] = !treeState.open[path];
|
||||
const box = $(treeBoxId(path));
|
||||
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
|
||||
if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構';
|
||||
if (!box) return;
|
||||
box.style.display = treeState.open[path] ? '' : 'none';
|
||||
if (!treeState.open[path]) return;
|
||||
if (treeState.data[path] !== undefined) { renderFolderTree(path); return; }
|
||||
renderFolderTree(path); // 先畫「讀取中…」
|
||||
try {
|
||||
const t = await go.GetFolderTree(path);
|
||||
treeState.data[path] = t || null; // 後端回 null=還沒回報過
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="err">讀不到這個資料夾的結構:${esc(String(e))}</div>`;
|
||||
return;
|
||||
}
|
||||
renderFolderTree(path);
|
||||
}
|
||||
|
||||
// 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。
|
||||
function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); }
|
||||
|
||||
function setAllTreeNodes(path, open) {
|
||||
const tree = treeState.data[path];
|
||||
if (!tree) return;
|
||||
const st = {};
|
||||
(tree.nodes || []).forEach((n) => { st[n.path] = open; });
|
||||
if (!open) st[''] = true; // 全部收合時根自己留著,不然整棵消失、看起來像壞了
|
||||
treeState.nodes[path] = st;
|
||||
renderFolderTree(path);
|
||||
}
|
||||
|
||||
function wireTree() {
|
||||
document.querySelectorAll('[data-tnode]').forEach((el) => {
|
||||
const toggle = () => {
|
||||
const root = el.dataset.troot, np = el.dataset.tnode;
|
||||
const st = treeState.nodes[root] || (treeState.nodes[root] = {});
|
||||
const tree = treeState.data[root] || { nodes: [] };
|
||||
const n = (tree.nodes || []).find((x) => x.path === np);
|
||||
st[np] = !(st[np] === undefined ? (n && n.depth === 0) : st[np]);
|
||||
renderFolderTree(root);
|
||||
};
|
||||
el.onclick = toggle;
|
||||
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
|
||||
});
|
||||
document.querySelectorAll('[data-texpand]').forEach((el) => {
|
||||
el.onclick = () => setAllTreeNodes(el.dataset.texpand, true);
|
||||
});
|
||||
document.querySelectorAll('[data-tcollapse]').forEach((el) => {
|
||||
el.onclick = () => setAllTreeNodes(el.dataset.tcollapse, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pageLib(s, idx) {
|
||||
const a = s.accounts[idx];
|
||||
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
|
||||
@@ -320,9 +531,12 @@ function pageLib(s, idx) {
|
||||
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
|
||||
<div class="folder">
|
||||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
|
||||
<span class="tag">自動同步中</span>
|
||||
<span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
|
||||
<button class="ghost" data-tree="${esc(f.path)}">${treeState.open[f.path] ? '▾' : '▸'} 資料夾結構</button>
|
||||
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
|
||||
</div>`).join('')
|
||||
</div>
|
||||
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>
|
||||
${f.resyncNote ? `<div class="d folder-note">${esc(f.resyncNote)}</div>` : ''}`).join('')
|
||||
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
|
||||
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
|
||||
}
|
||||
@@ -739,6 +953,12 @@ function wire() {
|
||||
document.querySelectorAll('[data-rm]').forEach((b) => {
|
||||
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
|
||||
});
|
||||
// #44:資料夾結構。換頁/重畫之後把本來就展開著的那幾棵補回去——
|
||||
// 不補的話使用者每次切回這一頁都得重按一次(狀態在 treeState,畫面卻是空的)。
|
||||
document.querySelectorAll('[data-tree]').forEach((b) => {
|
||||
b.onclick = () => toggleFolderTree(b.dataset.tree);
|
||||
if (treeState.open[b.dataset.tree]) renderFolderTree(b.dataset.tree);
|
||||
});
|
||||
|
||||
// ── App 啟動器(arcrun-rag#137)──
|
||||
on('apConnect', showConnect);
|
||||
|
||||
@@ -412,3 +412,85 @@ input[type=text], input[type=password] {
|
||||
word-break: break-all; white-space: pre-wrap;
|
||||
}
|
||||
.wfitem .out.bad { color: var(--err); }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半;形狀規格 `inkstone/Arcrun#144`)
|
||||
|
||||
leo 2026-08-19(驗 1.4.49 的 markmap 之後打回):
|
||||
「我的需求是**向右向下**,類似 terminal 的 tree,**一列一列向下往後退縮**,
|
||||
緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**⋯⋯
|
||||
可以用滑鼠輕易操作,**不會佔用大面積**。」
|
||||
|
||||
⇒ 這一段的每個數字都在服務那句話:
|
||||
· 一列 = 一個 flex row,靠 padding-left 退縮(縮排 16px/層,同檔案總管量級)
|
||||
· 行高壓到 20px 上下、字級 13px ⇒ 1080p 一個畫面塞得下數十列
|
||||
· 三角形+資料夾 icon 在最左,數字與原因往右排,掃視時眼睛只走一條直線
|
||||
🔴 顏色一個 hex 都不准出現(本檔開頭那條規矩)——全部走 arcrun-cis.css 的變數。
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ftbox {
|
||||
margin: -3px 0 9px; padding: 8px 10px 6px;
|
||||
background: var(--well); border-radius: 10px;
|
||||
}
|
||||
.fthead {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding-bottom: 7px; margin-bottom: 5px;
|
||||
border-bottom: 1px solid rgba(var(--ink-rgb), .08);
|
||||
}
|
||||
.fthead button { padding: 4px 9px; font-size: 12px; }
|
||||
/* 樹本體:檔案總管那種「緊湊但點得到」的密度。
|
||||
overflow-x 讓很深的樹自己捲,不去撐破右側版面。 */
|
||||
.ftbody { overflow-x: auto; padding: 1px 0; }
|
||||
.ftrow {
|
||||
display: flex; align-items: baseline; gap: 7px;
|
||||
padding: 2px 4px 2px 0; font-size: 13px; line-height: 1.5;
|
||||
white-space: nowrap; border-radius: 5px;
|
||||
}
|
||||
/* 有子資料夾的那幾列才可點——整列都是點擊區(三角形只有 12px,滑鼠不好瞄)。 */
|
||||
.ftrow.has { cursor: pointer; user-select: none; }
|
||||
.ftrow.has:hover { background: rgba(var(--amber-rgb), .08); }
|
||||
.ftrow.has:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
|
||||
/* 三角形:**用 CSS 畫,不用字元**。
|
||||
leo 2026-08-19 打回 markmap 的其中一條理由就是「**展開字很小**⋯⋯
|
||||
用戶要擁有觸控板,否則很難閱讀」——▸▾ 這兩個字在不同平台的中文字型裡
|
||||
大小與位置都不一樣(Windows 常被當全形處理),畫出來的則到處一致。 */
|
||||
.ftrow .tw {
|
||||
width: 14px; flex: none; align-self: center;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ftrow .tw i {
|
||||
display: block; width: 0; height: 0;
|
||||
border-left: 5px solid rgba(var(--ink-rgb), .5);
|
||||
border-top: 4px solid transparent; border-bottom: 4px solid transparent;
|
||||
transition: transform .12s;
|
||||
}
|
||||
.ftrow.open .tw i { transform: rotate(90deg); }
|
||||
.ftrow.has:hover .tw i { border-left-color: var(--amber); }
|
||||
.ftrow .ic { flex: none; font-size: 12.5px; }
|
||||
.ftrow .nm { font-weight: 500; }
|
||||
.ftrow .num {
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: 12px;
|
||||
color: rgba(var(--ink-rgb), .55);
|
||||
}
|
||||
/* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */
|
||||
.ftrow .num.full { color: var(--ink); }
|
||||
/* 🔴 選擇器**不要**綁在 .ftrow 底下:頁首那句「為什麼只收這些」也用同一個 class,
|
||||
綁死就只有樹裡面吃得到樣式、頁首那句會掉回 16px 的內文級——實撞過一次,
|
||||
畫面上那句話比整棵樹都大聲,喧賓奪主。 */
|
||||
/* 「為什麼沒上去」是次要資訊:**它可以被截斷,資料夾名字不行**。
|
||||
(整棵未收的理由是一整句話,不截的話一列會長到把樹推出畫面外。)
|
||||
截掉的部分留在 title 裡,滑鼠停著就看得到全文。 */
|
||||
.ftbox .why {
|
||||
font-size: 12px; color: rgba(var(--ink-rgb), .45);
|
||||
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.fthead .why { flex: 1; min-width: 0; line-height: 1.6; }
|
||||
/* 整棵沒走進去:語氣是告知不是錯誤,所以不用 --err。 */
|
||||
.ftrow .skip {
|
||||
font-size: 11.5px; color: rgba(var(--ink-rgb), .5);
|
||||
border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 5px; padding: 0 5px;
|
||||
}
|
||||
.ftmsg {
|
||||
padding: 6px 2px 2px; font-size: 12px; line-height: 1.65;
|
||||
color: rgba(var(--ink-rgb), .45);
|
||||
}
|
||||
|
||||
@@ -475,6 +475,35 @@ func (c *DirectConfig) triggerURL(workflow string) string {
|
||||
return fmt.Sprintf("%s/webhooks/named/%s/%s/trigger", c.CypherURL, c.Namespace, workflow)
|
||||
}
|
||||
|
||||
// folderTreeURL 組出「回報資料夾樹」的端點(InkStoneCo#44 線 A)。
|
||||
//
|
||||
// 🔴 為什麼不是 named-webhook(也就是不做成一條 workflow):這件事是**登記**,
|
||||
// 不是知識萃取——它寫的是 portal 的登記簿(庫目錄+資料夾節點),與
|
||||
// `/portal/daemon/extract`(extract_workersai.go:86)、`/portal/daemon/libraries`
|
||||
// 同一族、同一把 `X-Arcrun-API-Key`。做成 workflow 會為了「把結構化資料寫進登記簿」
|
||||
// 在 workflow 裡塞一串 code 節點,那正是「表面走 Arcrun、實際整段寫 JS」的腹語術。
|
||||
func (c *DirectConfig) folderTreeURL() string {
|
||||
return c.CypherURL + "/portal/daemon/folder-tree"
|
||||
}
|
||||
|
||||
// countsAsDocument 回答「這筆結果算不算**使用者的一份文件**」——
|
||||
// 也就是該不該進 status.json 的 `extracted_ok` / `extract_failed`(=托盤上那句
|
||||
// 「已整理 N 份 / ⚠ N 份失敗」)。
|
||||
//
|
||||
// 🔴 `folder_tree` 不算。它送的是**畫面用的資料夾結構**,不是知識、不是使用者的檔案:
|
||||
// 收端只寫一把 KV,一份文件都沒有經手。
|
||||
//
|
||||
// 📌 這是本分支自己踩到的(InkStoneCo#44):漏掉這道閘的症狀是**空資料夾**——
|
||||
// 一個檔都沒有,樹卻照樣要送(那正是 arcrun-rag#106 要的),一旦雲端連不上,
|
||||
// 使用者的托盤就會顯示「⚠ 1 份失敗」而他根本沒有半份檔案。
|
||||
// 「講一個假的 1」跟「安靜地少講」對使用者是同一件事(同 #104 那條紅線)。
|
||||
//
|
||||
// `inventory`(資料夾總覽卡)刻意**仍然算**:它是真的被寫進知識庫的一張卡,
|
||||
// 使用者在雲端查得到它——與這裡的樹不是同一種東西。
|
||||
func countsAsDocument(r DirectResult) bool {
|
||||
return r.Type != "folder_tree"
|
||||
}
|
||||
|
||||
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
|
||||
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
data, err := json.Marshal(body)
|
||||
@@ -492,10 +521,25 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
|
||||
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
|
||||
full, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
snippet := full
|
||||
if len(snippet) > 1024 {
|
||||
snippet = snippet[:1024]
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
|
||||
// named-webhook 觸發成功一律回 200,工作流內部失敗藏在 body 裡
|
||||
//(2026-08-26 實測 `InkStoneCo`:26 份蓋了「已送達」章,雲端只有 4 份)。
|
||||
// 判斷放在這裡而不是各呼叫端:**「忘了接」這個失敗模式不該存在**
|
||||
//(同 ingestplan.go 把兩張排除表收成一張的理由)。
|
||||
if msg := webhookFailure(string(full)); msg != "" {
|
||||
return resp.StatusCode, string(snippet), errors.New(msg)
|
||||
}
|
||||
return resp.StatusCode, string(snippet), nil
|
||||
}
|
||||
|
||||
@@ -664,6 +708,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。
|
||||
folderPlans := map[string]FolderPlanStatus{}
|
||||
|
||||
// `inkstone/InkStoneCo#44`(桌面小幫手那半):每個看守資料夾這一輪的樹。
|
||||
// 與 folderPlans 同一套 key 與同一套覆蓋語意。knownRoots 記「這一輪打算處理哪些根」
|
||||
// ——合併時要靠它把已經不看守的根從快照裡刪掉(見 MergeFolderTreeStore ③)。
|
||||
folderTrees := map[string]FolderTree{}
|
||||
var knownRoots []string
|
||||
|
||||
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
|
||||
var totalProgress SyncProgress
|
||||
var stuckReasons []string
|
||||
@@ -672,6 +722,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 +787,19 @@ 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...)
|
||||
// #44:這一根的樹。**掃壞了(Nodes 空)就不要覆蓋上一輪的好資料**——
|
||||
// 合併時沿用舊的(MergeFolderTreeStore ②),畫面不會突然變成「還沒回報」。
|
||||
knownRoots = append(knownRoots, root)
|
||||
if len(rp.Tree.Nodes) > 0 {
|
||||
folderTrees[root] = rp.Tree
|
||||
}
|
||||
// #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
|
||||
@@ -740,7 +807,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
}
|
||||
for i := range r {
|
||||
r[i].Account = accHost // t104: 標明所屬帳號
|
||||
if cfg.Extractor != "" {
|
||||
if cfg.Extractor != "" && countsAsDocument(r[i]) {
|
||||
switch r[i].Status {
|
||||
case "ingested":
|
||||
accSt.ExtractedOK++
|
||||
@@ -828,6 +895,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 迭代順序隨機,不排的話清單會自己跳動)。
|
||||
@@ -882,6 +950,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
|
||||
if cfg.Extractor != "" {
|
||||
for _, r := range results {
|
||||
if !countsAsDocument(r) {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case "ingested":
|
||||
st.ExtractedOK++
|
||||
@@ -949,6 +1020,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if serr := SaveSyncStatus(statusPath, st); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
|
||||
}
|
||||
|
||||
// `inkstone/InkStoneCo#44`:本機那份樹快照。
|
||||
// 🔴 **刻意不放進 status.json**:桌面小幫手每秒讀一次 status.json(tick),
|
||||
// 而一棵樹上限 300 個節點、一台機器可能看守好幾個資料夾
|
||||
// ⇒ 併進去等於讓每一秒都去解析幾百 KB 只有「使用者按開樹」那一刻才要用的資料。
|
||||
// 分成兩個檔,讀的人各取所需(小幫手只在展開時才讀這一份)。
|
||||
treePath := FolderTreeStorePath(cfg.Manifest)
|
||||
prevTrees, _ := LoadFolderTreeStore(treePath) // 讀不到=沒有上一輪,零值可用
|
||||
if terr := SaveFolderTreeStore(treePath, MergeFolderTreeStore(prevTrees, folderTrees, knownRoots, now)); terr != nil {
|
||||
fmt.Fprintf(os.Stderr, "資料夾結構寫入失敗(不擋看守):%v\n", terr)
|
||||
}
|
||||
}
|
||||
|
||||
return results, exit, lastPayload
|
||||
@@ -1204,6 +1286,14 @@ 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
|
||||
// Tree=這一根這一輪算出來的資料夾樹(`inkstone/InkStoneCo#44`,桌面小幫手那半)。
|
||||
// 🔴 **就是送上雲端的那一棵**(BuildFolderTree 的產物原件),不是為了畫面另算一份。
|
||||
// 呼叫端把它落地成 folder-trees.json,小幫手離線也攤得開(理由見 foldertree.go 檔尾)。
|
||||
Tree FolderTree
|
||||
}
|
||||
|
||||
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
|
||||
@@ -1255,6 +1345,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 +1427,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 的機械收口),所以刻意放在:
|
||||
@@ -1334,6 +1454,23 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
}
|
||||
}
|
||||
|
||||
// InkStoneCo#44 線 A(leo 2026-08-17):把「這個資料夾長什麼樣、每一層同步了幾份/
|
||||
// 總共幾份」送上 portal。與上面的總覽卡刻意分開——那張是給檢索用的 markdown 卡,
|
||||
// 這棵是給畫面用的結構化資料,消費者不同(理由全文見 foldertree.go 檔頭)。
|
||||
//
|
||||
// 位置與總覽卡同一段的三個理由完全通用(不等 LLM/在額度閘之外/反映現況),
|
||||
// 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**(arcrun-rag#106 的情境本身),
|
||||
// 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時
|
||||
// 內容雜湊自然擋住重送,不需要事件當第二道閘。
|
||||
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
|
||||
payload.AllExcludedDirs, plan, runNow)
|
||||
if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil {
|
||||
results = append(results, *treeRes)
|
||||
if treeRes.Status != "planned" {
|
||||
saveManifest() // 記住 folder_tree_hash/失敗退避
|
||||
}
|
||||
}
|
||||
|
||||
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
|
||||
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
|
||||
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
|
||||
@@ -1366,13 +1503,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
|
||||
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
|
||||
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
|
||||
//
|
||||
// 🔴 arcrun-rag#104 comment 4480(t217,2026-08-27):cap 過去套在「排序後的原始
|
||||
// 清單」上,而不是套在「這輪真的會被嘗試」的事件上。退避中/已達重試上限的檔案
|
||||
// 不會因為正在退避就往後排(mtime 沒變、排序不變)⇒ 只要前 perRunCap 名一直是
|
||||
// 同一批持續失敗的檔案,它們就會**永久佔滿名額**,排在後面的事件不管跑幾輪都
|
||||
// 排不到——這正是 leo 實測到的「1691→1880→1936 筆從不減少」,佇列本身就是問題。
|
||||
// 修法:cap 改套在 partitionRetryEligible 分出來的 ready(會真的嘗試)事件上;
|
||||
// 退避中的事件(waiting)不佔嘗試名額,讓排在它們後面、真正還沒被嘗試過的事件
|
||||
// 有機會遞補上來。waiting 仍要展示(診斷用、不是安靜消失),但同樣設一個上限,
|
||||
// 避免巨量積壓(實據 KB 資料夾 ~1900 筆退避中)把單輪結果與 status.json 灌爆。
|
||||
orderedEvents := sortEventsNewestFirst(absRoot, payload.Events)
|
||||
perRunCap := cfg.effectiveMaxEventsPerRun()
|
||||
readyEvents, waitingEvents := partitionRetryEligible(m, orderedEvents, now, cfg.ForceSync, qs.inCooldown(runNow))
|
||||
|
||||
deferredCount := 0
|
||||
if len(orderedEvents) > perRunCap {
|
||||
deferredCount = len(orderedEvents) - perRunCap
|
||||
orderedEvents = orderedEvents[:perRunCap]
|
||||
if len(readyEvents) > perRunCap {
|
||||
deferredCount = len(readyEvents) - perRunCap
|
||||
readyEvents = readyEvents[:perRunCap]
|
||||
}
|
||||
visibleWaiting := waitingEvents
|
||||
if len(visibleWaiting) > perRunCap {
|
||||
deferredCount += len(visibleWaiting) - perRunCap
|
||||
visibleWaiting = visibleWaiting[:perRunCap]
|
||||
}
|
||||
orderedEvents = append(readyEvents, visibleWaiting...)
|
||||
|
||||
for _, ev := range orderedEvents {
|
||||
switch ev.Type {
|
||||
@@ -1528,6 +1683,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:記下失敗並排定退避,否則下輪又把它當新檔重試
|
||||
@@ -1669,7 +1830,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
|
||||
// t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
|
||||
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
|
||||
rp := rootProgress{Progress: m.Progress()}
|
||||
rp := rootProgress{Progress: m.Progress(), Tree: tree}
|
||||
for _, e := range m.Entries {
|
||||
if e != nil && e.FailCount >= MaxFailBeforeSkip {
|
||||
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,
|
||||
@@ -1678,6 +1839,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
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -55,6 +55,12 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
// 假 cypher:收 rag_ingest_card、驗 payload、記帳
|
||||
var posted []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹走 portal 登記端點(不是 workflow webhook),
|
||||
// 它不是「卡片」也不含任何原文 ⇒ 不算進 posted,也不算打錯端點。
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_ingest_card/trigger") {
|
||||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||||
}
|
||||
@@ -281,9 +287,12 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
|
||||
var hitCard, hitDirect bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 🔴 這裡以前寫的是 `else { hitDirect = true }`——「不是卡片端點就當成直送端點」。
|
||||
// InkStoneCo#44 加了第三個端點(資料夾樹)之後,那個 else 就開始說謊。
|
||||
// 改成指名要驗的那條路:本測要守的契約是「**原文不出機**」=不准打 rag_ingest_direct。
|
||||
if strings.Contains(r.URL.Path, "rag_ingest_card") {
|
||||
hitCard = true
|
||||
} else {
|
||||
} else if strings.Contains(r.URL.Path, "rag_ingest_direct") {
|
||||
hitDirect = true
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
|
||||
@@ -133,8 +133,10 @@ func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
|
||||
if r.Status != "planned" {
|
||||
t.Fatalf("dry-run 事件應為 planned:%+v", r)
|
||||
}
|
||||
if r.Type == "inventory" {
|
||||
continue // 結構先行:每根各有一張總覽卡(planned),檔案事件另計
|
||||
if r.Type == "inventory" || r.Type == "folder_tree" {
|
||||
// 每根各有一張總覽卡與一棵資料夾樹(都是 planned),檔案事件另計。
|
||||
// 兩者都不是檔案事件:一個是結構先行的知識卡,一個是 portal 畫面的樹。
|
||||
continue
|
||||
}
|
||||
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
|
||||
}
|
||||
|
||||
@@ -107,3 +107,41 @@ func sortEventsNewestFirst(absRoot string, events []Event) []Event {
|
||||
func resumeAfterCapMessage(remaining int) string {
|
||||
return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining)
|
||||
}
|
||||
|
||||
// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組:
|
||||
// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、
|
||||
// 帳號沒在額度冷卻中。
|
||||
// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達
|
||||
// MaxFailBeforeSkip、或整個帳號正在額度冷卻。
|
||||
//
|
||||
// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的
|
||||
// case "removed"),一律歸 ready,維持既有行為不變。
|
||||
//
|
||||
// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480,t217):
|
||||
// 舊版直接對排序後的原始清單套用 perRunCap(`orderedEvents[:perRunCap]`)。
|
||||
// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap
|
||||
// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪
|
||||
// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到,
|
||||
// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因:
|
||||
// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。
|
||||
//
|
||||
// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額,
|
||||
// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。
|
||||
func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) {
|
||||
for _, ev := range events {
|
||||
if ev.Type == "removed" {
|
||||
ready = append(ready, ev)
|
||||
continue
|
||||
}
|
||||
if coolingDown {
|
||||
waiting = append(waiting, ev)
|
||||
continue
|
||||
}
|
||||
if m.ShouldRetry(ev.Path, now, force) {
|
||||
ready = append(ready, ev)
|
||||
} else {
|
||||
waiting = append(waiting, ev)
|
||||
}
|
||||
}
|
||||
return ready, waiting
|
||||
}
|
||||
|
||||
+138
-1
@@ -106,6 +106,11 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var order []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹走 portal 登記端點,body 沒有 page_name。
|
||||
// 不先擋掉,下面那行型別斷言會 panic,而測試會變成掛住 8 分鐘(見該 helper 的說明)。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
@@ -196,10 +201,137 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480,t217)──
|
||||
//
|
||||
// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」,
|
||||
// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」
|
||||
// 「佇列就是問題本身」。
|
||||
//
|
||||
// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續
|
||||
// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的
|
||||
// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、
|
||||
// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。
|
||||
//
|
||||
// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的
|
||||
// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案
|
||||
// 排在它們後面。單輪上限=3。
|
||||
//
|
||||
// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個
|
||||
// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。
|
||||
// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。
|
||||
// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒
|
||||
// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。
|
||||
// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額,
|
||||
// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。
|
||||
func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。
|
||||
blockers := []string{"blocker-a", "blocker-b", "blocker-c"}
|
||||
for i, n := range blockers {
|
||||
writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute))
|
||||
}
|
||||
// 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。
|
||||
healthy := []string{"h5", "h4", "h3", "h2", "h1"}
|
||||
for i, n := range healthy {
|
||||
writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute))
|
||||
}
|
||||
|
||||
failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true}
|
||||
var mu sync.Mutex
|
||||
var succeededPages []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
pageName, _ := m["page_name"].(string)
|
||||
if strings.HasPrefix(pageName, "資料夾總覽") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
if failingPages[pageName] {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"boom"}`))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
succeededPages = append(succeededPages, pageName)
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
|
||||
}},
|
||||
})
|
||||
})()
|
||||
|
||||
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,
|
||||
MaxEventsPerRun: 3,
|
||||
}
|
||||
|
||||
// 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。
|
||||
results1, _, _ := RunDirectOnce(cfg, false)
|
||||
if got := ingestedPaths(results1); len(got) != 0 {
|
||||
t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got)
|
||||
}
|
||||
var failedCount int
|
||||
for _, r := range results1 {
|
||||
if r.Status == "failed" {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 3 {
|
||||
t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d:%+v", failedCount, results1)
|
||||
}
|
||||
|
||||
// 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
ingested2 := ingestedPaths(results2)
|
||||
if len(ingested2) != 3 {
|
||||
t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+
|
||||
"如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+
|
||||
"健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d:%+v",
|
||||
len(ingested2), results2)
|
||||
}
|
||||
wantSecond := []string{"h5.md", "h4.md", "h3.md"}
|
||||
for i, w := range wantSecond {
|
||||
if ingested2[i] != w {
|
||||
t.Fatalf("第二輪順序=%v,want %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond)
|
||||
}
|
||||
}
|
||||
// blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。
|
||||
for _, r := range results2 {
|
||||
if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" {
|
||||
if r.Status != "skipped" {
|
||||
t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(succeededPages) != 3 {
|
||||
t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestedPaths(results []DirectResult) []string {
|
||||
var out []string
|
||||
for _, r := range results {
|
||||
if r.Status == "ingested" && r.Type != "inventory" { // 結構先行:總覽卡另計
|
||||
// 只數「檔案事件」。總覽卡(inventory)與資料夾樹(folder_tree,InkStoneCo#44)
|
||||
// 都是每輪的結構回報,不是使用者的檔案——同 countsAsDocument 的那條線。
|
||||
if r.Status == "ingested" && r.Type != "inventory" && r.Type != "folder_tree" {
|
||||
out = append(out, r.Path)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +362,11 @@ func TestDirect_ResumeAfterInterruption(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var posted []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹的 body 沒有 path ⇒ 不擋掉的話會被記成一筆空字串,
|
||||
// 本測就會看到「送了 4 次」而誤判斷點續傳失效。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
|
||||
@@ -51,6 +51,46 @@ import (
|
||||
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
|
||||
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
// maxWorkersAIExtractBytes=送進雲端萃取的純文字上限(位元組)。
|
||||
//
|
||||
// 🔴 為什麼要有這道閘(2026-08-26 實測 `InkStoneCo`,非推測):
|
||||
// 這條路沒有任何長度判斷——整份原稿原封不動塞進 prompt。leo 的
|
||||
// `system-dev/wiki/mistakes.md`(562 KB)、`status.md`(401 KB)、
|
||||
// `status-archive-2026-08.md`(511 KB)因此每一輪都撞同一面牆,
|
||||
// 而使用者看到的是這串**沒有人讀得懂的東西**:
|
||||
//
|
||||
// 本地萃取失敗:雲端萃取失敗(HTTP 502):Workers AI 執行失敗:8007:
|
||||
// {"error":{"message":"This model's maximum context length is 131000 tokens…
|
||||
//
|
||||
// ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列)
|
||||
// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。
|
||||
//
|
||||
// 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報
|
||||
// 「prompt contains at least 122,xxx tokens」,而可用輸入是
|
||||
// 131,000 − 8,192(輸出) ≈ 122,800 ⇒ 中文原稿約 3.3 位元組/token。
|
||||
// 300 KB ≈ 9 萬 token,留了三成餘裕給提示詞本身與英數混排的變異。
|
||||
//
|
||||
// ⚠️ 這個上限只綁**這條路**(雲端 llama-4-scout 的 131k 視窗)。
|
||||
// gemma 路走 Gemini、視窗大一個數量級,不受此限——判準跟著模型走,
|
||||
// 不做成全域常數,免得換模型時有人以為它是產品規格。
|
||||
const maxWorkersAIExtractBytes = 300_000
|
||||
|
||||
// tooBigForWorkersAI 回傳「這份原稿太大,這條路讀不完」的人話理由;
|
||||
// 沒超過回空字串。
|
||||
//
|
||||
// 🔴 訊息是產品文案不是 debug 字串:要講**多大**、**為什麼不收**、**他能做什麼**,
|
||||
// 而且不准出現狀態碼、模型名或 token 這種只有工程師看得懂的詞。
|
||||
func tooBigForWorkersAI(srcText, relPath string) string {
|
||||
if len(srcText) <= maxWorkersAIExtractBytes {
|
||||
return ""
|
||||
}
|
||||
// 中文一個字約 3 位元組——換算成「字數」才是使用者對得上的單位。
|
||||
wan := len(srcText) / 3 / 10000
|
||||
return fmt.Sprintf(
|
||||
"這份檔太大了(約 %d 萬字),雲端的整理模型一次讀不完,所以這次沒有收它。"+
|
||||
"把它拆成幾份小一點的檔就會自動收進來。", wan)
|
||||
}
|
||||
|
||||
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
|
||||
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
|
||||
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
|
||||
@@ -74,6 +114,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||||
}
|
||||
|
||||
// 🔴 明知送出去一定會失敗,就不要送(見 maxWorkersAIExtractBytes)。
|
||||
// 早一步擋下=不燒額度、不占佇列,而且使用者看到的是人話不是上游錯誤碼。
|
||||
if why := tooBigForWorkersAI(srcText, relPath); why != "" {
|
||||
return nil, fmt.Errorf("%s", why)
|
||||
}
|
||||
|
||||
pageName := pageNameOf(relPath)
|
||||
// #134:prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
|
||||
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
|
||||
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
// 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,對使用者是同一件事)。
|
||||
MaxFolderTreeNodes = 300
|
||||
|
||||
// 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=這整棵子樹被剪掉了(沒有走進去),所以上面的數字**全部是 0 而且不是事實**。
|
||||
// 🔴 畫面看到 Skipped 必須改講 SkipReason,不准顯示 `0/0`——那會是我們自己編的數字。
|
||||
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
|
||||
}
|
||||
|
||||
// 分子: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(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
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// foldertree_local_test.go — 本機那份樹快照(`inkstone/InkStoneCo#44` 桌面小幫手那半)
|
||||
//
|
||||
// 這裡測的不是「樹算得對不對」(那在 foldertree_test.go,已驗過、沒動),
|
||||
// 而是**落地那一層的三個會讓畫面說謊的情境**:
|
||||
//
|
||||
// ① 這一輪掃壞了 ⇒ 不准把上一輪的好資料蓋成空的(畫面不該突然說「還沒回報」)
|
||||
// ② 使用者移除了資料夾 ⇒ 快照裡那棵要消失(不然他以為沒移掉)
|
||||
// ③ 存進去、讀回來,要是同一棵樹(桌面與 portal 看到的是同一份資料)
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleTree(root string, synced, total int) FolderTree {
|
||||
return FolderTree{
|
||||
Library: "lib-" + filepath.Base(root),
|
||||
DisplayName: filepath.Base(root),
|
||||
Root: root,
|
||||
Mode: "all",
|
||||
Reason: "整個資料夾都收",
|
||||
Nodes: []FolderNode{
|
||||
{Path: "", Name: filepath.Base(root), Parent: "-", Depth: 0,
|
||||
TotalFiles: total, SyncedFiles: synced, UnsupportedFiles: total - synced},
|
||||
{Path: "sub", Name: "sub", Parent: "", Depth: 1, TotalFiles: 2, SyncedFiles: 2},
|
||||
},
|
||||
TotalNodes: 2,
|
||||
GeneratedAt: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 存-讀來回:一個欄位都不准掉。
|
||||
// 🔴 這條的重點不是 JSON 會不會動,是**桌面看到的必須是送上雲端的那一棵**——
|
||||
// 少一個欄位就等於本機悄悄長出了第二套資料形狀。
|
||||
func TestFolderTreeStoreRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := FolderTreeStorePath(filepath.Join(dir, "manifest.json"))
|
||||
if want := filepath.Join(dir, "folder-trees.json"); path != want {
|
||||
t.Fatalf("快照該與 manifest 同目錄:got %s want %s", path, want)
|
||||
}
|
||||
in := FolderTreeStore{
|
||||
UpdatedAt: "2026-08-26T00:00:00Z",
|
||||
Trees: map[string]FolderTree{"/a/b": sampleTree("/a/b", 3, 5)},
|
||||
}
|
||||
if err := SaveFolderTreeStore(path, in); err != nil {
|
||||
t.Fatalf("存檔失敗:%v", err)
|
||||
}
|
||||
out, err := LoadFolderTreeStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("讀回失敗:%v", err)
|
||||
}
|
||||
got, ok := out.Trees["/a/b"]
|
||||
if !ok {
|
||||
t.Fatalf("讀回來少了那棵樹:%+v", out)
|
||||
}
|
||||
if got.Library != "lib-b" || got.Reason != "整個資料夾都收" || got.Mode != "all" {
|
||||
t.Fatalf("欄位掉了:%+v", got)
|
||||
}
|
||||
if len(got.Nodes) != 2 || got.Nodes[0].TotalFiles != 5 || got.Nodes[0].SyncedFiles != 3 ||
|
||||
got.Nodes[0].Parent != "-" || got.Nodes[1].Depth != 1 {
|
||||
t.Fatalf("節點對不上:%+v", got.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// 讀不到/壞掉的檔一律回可用的零值——桌面小幫手不該因為快照壞掉就整個畫不出來。
|
||||
func TestFolderTreeStoreLoadMissingOrBroken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := LoadFolderTreeStore(filepath.Join(dir, "nope.json"))
|
||||
if err == nil {
|
||||
t.Fatalf("檔案不存在該回 error")
|
||||
}
|
||||
if s.Trees == nil {
|
||||
t.Fatalf("零值也要能直接用(Trees 不可為 nil)")
|
||||
}
|
||||
bad := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(bad, []byte("{ not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := LoadFolderTreeStore(bad)
|
||||
if err == nil {
|
||||
t.Fatalf("壞檔該回 error")
|
||||
}
|
||||
if s2.Trees == nil {
|
||||
t.Fatalf("壞檔也要回可用的零值")
|
||||
}
|
||||
}
|
||||
|
||||
// ①:這一輪某個根沒算出樹(掃描失敗/還沒輪到)⇒ 沿用上一輪的,不清空。
|
||||
func TestMergeFolderTreeStoreKeepsPreviousWhenRoundProducedNothing(t *testing.T) {
|
||||
now := time.Unix(2000, 0)
|
||||
prev := FolderTreeStore{Trees: map[string]FolderTree{
|
||||
"/a": sampleTree("/a", 3, 5),
|
||||
"/b": sampleTree("/b", 1, 1),
|
||||
}}
|
||||
// 這一輪只有 /a 算出來,/b 掃失敗(fresh 裡沒有它),但兩個都還在看守清單上。
|
||||
out := MergeFolderTreeStore(prev, map[string]FolderTree{"/a": sampleTree("/a", 5, 5)}, []string{"/a", "/b"}, now)
|
||||
if len(out.Trees) != 2 {
|
||||
t.Fatalf("兩個根都該在:%+v", out.Trees)
|
||||
}
|
||||
if out.Trees["/a"].Nodes[0].SyncedFiles != 5 {
|
||||
t.Fatalf("這一輪算出來的該覆蓋舊的:%+v", out.Trees["/a"].Nodes[0])
|
||||
}
|
||||
if out.Trees["/b"].Nodes[0].SyncedFiles != 1 {
|
||||
t.Fatalf("這一輪沒算出來的該沿用上一輪,不是被清掉:%+v", out.Trees["/b"])
|
||||
}
|
||||
if out.UpdatedAt == "" {
|
||||
t.Fatalf("要記得更新時間,畫面才說得出「這是什麼時候的現況」")
|
||||
}
|
||||
}
|
||||
|
||||
// ②:使用者把資料夾從看守清單移掉 ⇒ 快照裡那棵要消失。
|
||||
// 留著的話他移除完還看得到那棵樹,會以為移除沒作用(arcrun-rag#46 修過的同一個病)。
|
||||
func TestMergeFolderTreeStoreDropsUnwatchedRoots(t *testing.T) {
|
||||
now := time.Unix(3000, 0)
|
||||
prev := FolderTreeStore{Trees: map[string]FolderTree{
|
||||
"/a": sampleTree("/a", 3, 5),
|
||||
"/gone": sampleTree("/gone", 1, 1),
|
||||
}}
|
||||
out := MergeFolderTreeStore(prev, map[string]FolderTree{}, []string{"/a"}, now)
|
||||
if _, still := out.Trees["/gone"]; still {
|
||||
t.Fatalf("已經不看守的根不該留在快照裡:%+v", out.Trees)
|
||||
}
|
||||
if _, ok := out.Trees["/a"]; !ok {
|
||||
t.Fatalf("還在看守的根不該被一起刪掉:%+v", out.Trees)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 接線測試:跑完一輪,本機真的多出那份快照 ────────────────────────────────
|
||||
//
|
||||
// 🔴 這支存在的理由就是 `arcrun-rag#104` 那條教訓的翻版:
|
||||
// 「東西做好了,但**不在會被執行的那條路上**」。
|
||||
// 樹早就算得出來(BuildFolderTree 有測、送雲端有測),但只要 RunDirectOnce
|
||||
// 沒把它落地,桌面小幫手就永遠一個字都看不到——而單看那些既有的測試,全綠。
|
||||
func TestRunDirectOnce把樹落地給桌面小幫手(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "我的知識庫")
|
||||
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
manifest := filepath.Join(base, "manifest.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifest,
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
ExtractorExplicit: true, // 隔離變因:本測驗的是落地,不是萃取
|
||||
}
|
||||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||||
t.Fatalf("同步應成功,exit=%d", exit)
|
||||
}
|
||||
|
||||
store, err := LoadFolderTreeStore(FolderTreeStorePath(manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("跑完一輪之後應該有快照可讀:%v", err)
|
||||
}
|
||||
tree, ok := store.Trees[root]
|
||||
if !ok {
|
||||
t.Fatalf("快照裡沒有這個看守根:%+v", store.Trees)
|
||||
}
|
||||
// 桌面畫面要的東西一件都不能少:根節點、子資料夾、分母、以及「為什麼沒收」的分類。
|
||||
if len(tree.Nodes) < 2 {
|
||||
t.Fatalf("樹太扁,畫不出巢狀結構:%+v", tree.Nodes)
|
||||
}
|
||||
if tree.DisplayName != "我的知識庫" {
|
||||
t.Errorf("根的顯示名要是使用者認得的資料夾名,got %q", tree.DisplayName)
|
||||
}
|
||||
var docs *FolderNode
|
||||
for i := range tree.Nodes {
|
||||
if tree.Nodes[i].Path == "docs" {
|
||||
docs = &tree.Nodes[i]
|
||||
}
|
||||
}
|
||||
if docs == nil {
|
||||
t.Fatalf("子資料夾 docs 不在樹上:%+v", tree.Nodes)
|
||||
}
|
||||
if docs.TotalFiles != 2 {
|
||||
t.Errorf("docs 分母=%d,應為 2(使用者在 Finder 裡看得到兩個檔):%+v", docs.TotalFiles, docs)
|
||||
}
|
||||
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
|
||||
t.Errorf("main.go 沒被分類到「為什麼沒上去」:%+v", docs)
|
||||
}
|
||||
|
||||
// 🔴 不准併進 status.json:那支每秒被讀一次,樹上限 300 個節點。
|
||||
raw, err := os.ReadFile(StatusFilePath(manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("status.json 該存在:%v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "folder_tree") || strings.Contains(string(raw), "\"nodes\"") {
|
||||
t.Errorf("樹跑進 status.json 了——那支每秒讀一次,不該扛這個")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// foldertree_test.go — 資料夾樹(InkStoneCo#44,leo 2026-08-17)
|
||||
//
|
||||
// leo 的驗收情境就是這裡測的東西:
|
||||
//
|
||||
// 「指定一個**有子資料夾、且混著支援與不支援檔案**的資料夾 → portal 出現那棵樹,
|
||||
// 每個節點兩個數字對得上實際檔案數」
|
||||
// 「指定一個**空資料夾** → 它也要出現」
|
||||
//
|
||||
// 🔴 本檔測的是「分子分母對不對得上實際檔案」——那正是這條規格的重點:
|
||||
// **兩個數字不相等是正常的,但差額必須解釋得了**。所以每個案例都驗不變式
|
||||
//
|
||||
// total == synced + pending + unsupported + excluded
|
||||
//
|
||||
// 而不是只驗「有送出去」。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// answeredFolderTreePost 讓「假 cypher」認得資料夾樹這個端點:是它就自己回 200 並回報 true,
|
||||
// 呼叫端 `return` 即可,不要把它算進卡片的統計裡。
|
||||
//
|
||||
// 🔴 這支存在的理由是一個實撞(2026-08-17,本分支自己踩的):本套件的假 cypher 幾乎都是
|
||||
// **萬用 handler**——「收到什麼都當成一次卡片 POST」,然後 `m["page_name"].(string)`。
|
||||
// #44 之後多了第三個端點(`/portal/daemon/folder-tree`),它的 body **沒有 page_name**
|
||||
// ⇒ 那些 handler 拿 nil 做型別斷言而 panic。而 panic 發生在 handler 的 goroutine 裡
|
||||
// ⇒ 測試不是紅,是**掛住到 8 分鐘 timeout**(比紅更難查:看起來像「測試很慢」)。
|
||||
//
|
||||
// ⇒ 以後再加第四個 daemon 端點時,凡是「數 POST 幾次/記 page_name」的 stub
|
||||
// 都要先問過這一句,不要再讓萬用 handler 去猜。
|
||||
func answeredFolderTreePost(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
return false
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return true
|
||||
}
|
||||
|
||||
// nodeOf 從一棵樹裡撈出某個路徑的節點(找不到就讓測試自己 Fatal,不回 nil 讓後面 panic)。
|
||||
func treeNodeOf(t *testing.T, tree FolderTree, path string) FolderNode {
|
||||
t.Helper()
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Path == path {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("樹裡沒有節點 %q,實際有:%v", path, nodePaths(tree))
|
||||
return FolderNode{}
|
||||
}
|
||||
|
||||
func nodePaths(tree FolderTree) []string {
|
||||
out := make([]string, 0, len(tree.Nodes))
|
||||
for _, n := range tree.Nodes {
|
||||
out = append(out, n.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 掃一個真實的暫存資料夾,把 Scan() 數出來的分母與 manifest 現況合成樹。
|
||||
// 刻意走真的 Scan()——分母的判準活在那趟 WalkDir 裡,繞過它就等於測了另一套實作。
|
||||
func buildTreeFromDisk(t *testing.T, root string) FolderTree {
|
||||
t.Helper()
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
plan := PlanIngest(root)
|
||||
payload, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan 失敗:%v", err)
|
||||
}
|
||||
return BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1786900000, 0))
|
||||
}
|
||||
|
||||
// ── leo 的驗收情境①:有子資料夾、混著支援與不支援的檔案 ────────────────────────
|
||||
|
||||
func TestFolderTree混合資料夾的兩個數字對得上(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "簡報.pptx"), "binary-ish")
|
||||
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
|
||||
mustWrite(t, filepath.Join(root, "img", "logo.png"), "PNG")
|
||||
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
|
||||
// 樹要長得出來(根+docs+img),而且根一定在第一個(畫面靠這個順序)
|
||||
if len(tree.Nodes) < 3 {
|
||||
t.Fatalf("節點太少:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.Nodes[0].Path != "" {
|
||||
t.Fatalf("根不在第一個:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.DisplayName == "" {
|
||||
t.Error("根沒有顯示名——使用者認得的是資料夾名字,不是空字串")
|
||||
}
|
||||
|
||||
// 🔴 不變式:每一層的分母必須解釋得完(差額=不支援+不收+還在路上)
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Skipped {
|
||||
continue // 沒走進去 ⇒ 數字全 0 且不是事實,見下一支測試
|
||||
}
|
||||
sum := n.SyncedFiles + n.PendingFiles + n.UnsupportedFiles + n.ExcludedFiles
|
||||
if n.TotalFiles != sum {
|
||||
t.Errorf("節點 %q 的差額解釋不了:total=%d 但 synced+pending+unsupported+excluded=%d(%+v)",
|
||||
n.Path, n.TotalFiles, sum, n)
|
||||
}
|
||||
}
|
||||
|
||||
// docs 這一層:Finder 裡看得到 3 個檔 ⇒ 分母就是 3(不是「我們收得下的那 1 個」)。
|
||||
// 這是整條規格的核心:分母是使用者數得出來的那個數字。
|
||||
docs := treeNodeOf(t, tree, "docs")
|
||||
if docs.TotalFiles != 3 {
|
||||
t.Errorf("docs 分母=%d,應為 3(.md/.pptx/.go 使用者都看得到):%+v", docs.TotalFiles, docs)
|
||||
}
|
||||
// 且「沒上傳的那些」要分得出是哪一類——leo:「不上傳通常是不支援,比如程式碼、不支援的格式」
|
||||
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
|
||||
t.Errorf("docs 有讀不了/不收的檔,卻一個都沒分類到:%+v", docs)
|
||||
}
|
||||
|
||||
// 每個節點都要有名字與父親,畫面才串得成一棵樹(少一環就變一堆浮著的節點)
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Path != "" && n.Parent == "-" {
|
||||
t.Errorf("非根節點 %q 的 parent 是 '-'(只有根才准)", n.Path)
|
||||
}
|
||||
if n.Name == "" {
|
||||
t.Errorf("節點 %q 沒有名字", n.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── leo 的驗收情境②:空資料夾也要出現(arcrun-rag#106)────────────────────────
|
||||
|
||||
func TestFolderTree空資料夾照樣有根節點(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
if len(tree.Nodes) != 1 || tree.Nodes[0].Path != "" {
|
||||
t.Fatalf("空資料夾應恰好有一個根節點:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.Nodes[0].TotalFiles != 0 {
|
||||
t.Errorf("空資料夾的分母應為 0:%+v", tree.Nodes[0])
|
||||
}
|
||||
// leo:「不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?」
|
||||
// ⇒ 樹要送得出去(有 library、有 root),收端才登記得起這個庫。
|
||||
if tree.Library == "" || tree.Root == "" {
|
||||
t.Errorf("空資料夾的樹缺 library/root,收端登記不了:%+v", tree)
|
||||
}
|
||||
}
|
||||
|
||||
// 走進得去但一個檔都沒有的子資料夾,也要有自己的節點
|
||||
//(leo 的規格是「不管那層有沒有文件,整棵樹都要採」)。
|
||||
func TestFolderTree空的子資料夾也有節點(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.md"), "# A")
|
||||
if err := os.MkdirAll(filepath.Join(root, "打算放東西的地方"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
n := treeNodeOf(t, tree, "打算放東西的地方")
|
||||
if n.TotalFiles != 0 || n.Skipped {
|
||||
t.Errorf("空的子資料夾應該是「有節點、數字 0、沒被剪掉」:%+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 冪等與雜湊 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFolderTreeHash不含時間(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.md"), "# A")
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
plan := PlanIngest(root)
|
||||
payload, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t1 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1000, 0))
|
||||
t2 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(9999, 0))
|
||||
if t1.Hash() != t2.Hash() {
|
||||
t.Error("同一份內容在不同時間算出不同雜湊 ⇒ 冪等閘失效,會每輪重送")
|
||||
}
|
||||
if t1.Hash() == "" {
|
||||
t.Error("雜湊算不出來")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 上傳行為:整棵一次送、成功後節流、失敗退避 ──────────────────────────────
|
||||
|
||||
func TestSyncFolderTree整棵一次送(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Arcrun-API-Key") == "" {
|
||||
t.Error("沒帶 X-Arcrun-API-Key(收端會 401)")
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
bodies = append(bodies, m)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 50; i++ { // 遠超過舊版 20 個節點一批的分批門檻
|
||||
mustWrite(t, filepath.Join(root, "d"+string(rune('a'+i%26))+string(rune('a'+i/26)), "x.md"), "# X")
|
||||
}
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
if len(tree.Nodes) <= 20 {
|
||||
t.Fatalf("前置沒建出足夠節點:%d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
now := time.Unix(1786900000, 0)
|
||||
res := syncFolderTree(cfg, root, m, tree, false, now)
|
||||
if res == nil || res.Status != "ingested" {
|
||||
t.Fatalf("應送達:%+v", res)
|
||||
}
|
||||
// 🔴 一次請求送完整棵樹:分批只在「收端每節點一次寫入」的設計下才有意義,
|
||||
// 而收端改成整棵覆寫後,分批只會留下半棵樹的中間狀態。
|
||||
if len(bodies) != 1 {
|
||||
t.Fatalf("應恰好一次請求,實際 %d 次", len(bodies))
|
||||
}
|
||||
sent, _ := bodies[0]["nodes"].([]any)
|
||||
if len(sent) != len(tree.Nodes) {
|
||||
t.Errorf("送出的節點數 %d ≠ 樹的節點數 %d(有被切掉)", len(sent), len(tree.Nodes))
|
||||
}
|
||||
if bodies[0]["sync_token"] == "" || bodies[0]["library"] != "kb" {
|
||||
t.Errorf("body 缺 sync_token/library:%+v", bodies[0])
|
||||
}
|
||||
|
||||
// 內容沒變 → 不重送
|
||||
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Hour)); again != nil {
|
||||
t.Errorf("內容沒變不該重送:%+v", again)
|
||||
}
|
||||
|
||||
// 內容變了、但還在最小間隔內 → 也不送(沒有這道閘,初次同步會每 5 秒送一次)
|
||||
changed := tree
|
||||
changed.Nodes = append([]FolderNode{{Path: "新", Name: "新", Parent: "", Depth: 1}}, tree.Nodes...)
|
||||
if soon := syncFolderTree(cfg, root, m, changed, false, now.Add(30*time.Second)); soon != nil {
|
||||
t.Errorf("最小間隔內不該送:%+v", soon)
|
||||
}
|
||||
// 過了最小間隔 → 送
|
||||
if later := syncFolderTree(cfg, root, m, changed, false, now.Add(folderTreeMinInterval+time.Second)); later == nil {
|
||||
t.Error("過了最小間隔應該要送")
|
||||
}
|
||||
if len(bodies) != 2 {
|
||||
t.Errorf("應總共兩次請求,實際 %d", len(bodies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFolderTree首次不受最小間隔限制(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root) // 空資料夾
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
// FolderTreeNextSend 是零值(從沒送過)⇒ 就算「現在」小於它也不能被擋
|
||||
// ——指定資料夾的當下就要看得見它,那正是 #106 的重點。
|
||||
if res := syncFolderTree(&DirectConfig{CypherURL: srv.URL, APIKey: "demo"}, root, m, tree, false, time.Unix(1, 0)); res == nil || res.Status != "ingested" {
|
||||
t.Fatalf("首次應直接送出:%+v", res)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("應打一次,實際 %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFolderTree失敗退避(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
cfg := &DirectConfig{CypherURL: "https://x.invalid", APIKey: "demo"}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
now := time.Unix(1786900000, 0)
|
||||
res := syncFolderTree(cfg, root, m, tree, false, now)
|
||||
if res == nil || res.Status != "failed" {
|
||||
t.Fatalf("應失敗:%+v", res)
|
||||
}
|
||||
if m.FolderTreeFailHash == "" || m.FolderTreeNextRetry == 0 {
|
||||
t.Errorf("失敗沒記退避 ⇒ 雲端一壞就每 5 秒重撞一次:%+v", m)
|
||||
}
|
||||
// 退避窗口內同一份內容不重撞
|
||||
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Minute)); again != nil {
|
||||
t.Errorf("退避窗口內不該重試:%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -16,17 +16,42 @@ import (
|
||||
|
||||
// splitInventory 把 results 拆成(總覽卡, 其餘)。既有測試斷言「幾個檔案事件」時
|
||||
// 用 rest;要驗結構先行本身時用 inv。
|
||||
// splitInventory 把「每輪都會做的結構回報」從「檔案事件」裡分出來。
|
||||
//
|
||||
// 有兩種結構回報,都不是檔案事件、都與 LLM 無關、都在額度閘之外:
|
||||
// - `inventory`=資料夾總覽**知識卡**(給檢索用的 markdown,走 rag_ingest_card)
|
||||
// - `folder_tree`=portal 畫面用的**結構化樹**(InkStoneCo#44,走 /portal/daemon/folder-tree)
|
||||
//
|
||||
// 🔴 2026-08-17:`folder_tree` 這一類是後加的,而本函式當時沒跟著加 ⇒ 五支既有測試
|
||||
// 突然把「樹的回報」當成一個檔案事件,紅了。**這正是「新增一種結果型別」時最容易漏的一環**:
|
||||
// 分類器只認得舊的那一種,新的就靜靜地落進 rest。以後再加第三種,記得改這裡。
|
||||
func splitInventory(results []DirectResult) (inv, rest []DirectResult) {
|
||||
for _, r := range results {
|
||||
if r.Type == "inventory" {
|
||||
switch r.Type {
|
||||
case "inventory":
|
||||
inv = append(inv, r)
|
||||
} else {
|
||||
case "folder_tree":
|
||||
// 樹不算總覽卡(回傳值只有兩個,刻意不擴成三個——呼叫端在意的是
|
||||
// 「哪些是檔案事件」,樹跟總覽卡一樣都屬於「不是檔案事件」那一堆)。
|
||||
// 要專門驗樹的測試自己從 results 撈 Type=="folder_tree"。
|
||||
default:
|
||||
rest = append(rest, r)
|
||||
}
|
||||
}
|
||||
return inv, rest
|
||||
}
|
||||
|
||||
// folderTreeResults 撈出本輪的資料夾樹回報(專門驗樹的測試用)。
|
||||
func folderTreeResults(results []DirectResult) []DirectResult {
|
||||
var out []DirectResult
|
||||
for _, r := range results {
|
||||
if r.Type == "folder_tree" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func invEntries(paths map[string]int64) map[string]*ManifestEntry {
|
||||
m := map[string]*ManifestEntry{}
|
||||
for p, mt := range paths {
|
||||
@@ -117,6 +142,10 @@ func TestDirectInventory_先於萃取且冪等(t *testing.T) {
|
||||
}
|
||||
var order []string // 按抵達順序記 page_name
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹不是卡片(沒有 page_name),不能算進「送了幾張卡」。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
|
||||
+62
@@ -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,26 @@ 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"`
|
||||
// ── InkStoneCo#44 線 A:資料夾樹的冪等記帳(見 foldertree.go)───────────────
|
||||
// FolderTreeHash=最後一次**成功送達雲端**的整棵樹內容雜湊;
|
||||
// FolderTreeFailHash/FolderTreeNextRetry=上一次送失敗的內容與退避到期時間。
|
||||
// 存在理由與上面 InventoryHash 那三個完全同款(t195:沒有退避,雲端一壞就是每 5 秒撞一次)。
|
||||
// 同屬 Manifest 層欄位 ⇒ Scan() 的 rebuild 只重建 Entries,天然跨輪存活。
|
||||
// FolderTreeNextSend=**成功送出後**的最小間隔到期時間(folderTreeMinInterval)。
|
||||
// 與上面兩個 *Retry 欄位是不同的東西:那兩個是「失敗退避」(t195 的慣例),
|
||||
// 這個是「成功也要節流」。需要它的原因是這棵樹沒有「本輪無事件就不送」那道閘
|
||||
//(空資料夾一個事件都沒有)⇒ 初次同步時內容雜湊每輪都變,沒有它就是每 5 秒
|
||||
// 一次雲端 KV 寫入,一小時燒光當天額度。
|
||||
FolderTreeHash string `json:"folder_tree_hash,omitempty"`
|
||||
FolderTreeFailHash string `json:"folder_tree_fail_hash,omitempty"`
|
||||
FolderTreeNextRetry int64 `json:"folder_tree_next_retry,omitempty"`
|
||||
FolderTreeNextSend int64 `json:"folder_tree_next_send,omitempty"`
|
||||
}
|
||||
|
||||
// QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。
|
||||
@@ -186,6 +227,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
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,31 @@ type TriggerPayload struct {
|
||||
// 而畫面上的數字是 **0**。講一個 0 跟安靜地少收,對使用者是同一件事。
|
||||
ExcludedDirs []ExcludedDir `json:"-"` // 已排序,上限 MaxExcludedDirsListed
|
||||
ExcludedDirCount int `json:"-"` // 總數(可能大於清單長度)
|
||||
|
||||
// ── InkStoneCo#44 線 A:portal 的資料夾樹(leo 2026-08-17)─────────────────
|
||||
// DirStats=走訪時**逐目錄**數出來的分母(key=相對監看根的路徑,`""`=根)。
|
||||
// AllExcludedDirs=整棵被剪掉的目錄,**未裁切**(上面那個 ExcludedDirs 為了畫面
|
||||
// 只留 20 筆;樹要畫得完整需要全量,不然「哪些沒收」會缺角)。
|
||||
//
|
||||
// 🔴 為什麼分母一定要在這裡數、不能另外走一趟:判斷「支不支援/收不收」的那一整套
|
||||
// 判準就活在這個 WalkDir 裡(allowedExt/docLikeExt/Plan.KeepsFile/TemplateOwns)。
|
||||
// 另外走一趟=同一件事第二份實作,必然漂移 ⇒ 畫面上的數字與實際收的檔對不起來,
|
||||
// 而 leo 要的正是「兩個數字的差額解釋得了」。
|
||||
// 同 `json:"-"`:給本機與 portal 用的,不進 collector-trigger schema。
|
||||
DirStats map[string]*dirStat `json:"-"`
|
||||
AllExcludedDirs []ExcludedDir `json:"-"`
|
||||
}
|
||||
|
||||
// dirStat=一個目錄「這一層直接放的檔案」的分類計數(不含子目錄;子樹合計由畫面自己疊,
|
||||
// 存兩套就是同一件事兩份實作,遲早對不起來)。
|
||||
//
|
||||
// 🔴 不變式:`total == supported + unsupported + excluded`。
|
||||
// 少一類就會出現「兩個數字的差額解釋不了」,而那正是 leo 這條規格要解掉的病。
|
||||
type dirStat struct {
|
||||
total int // 這一層看得到的檔案總數(分母)
|
||||
supported int // 通過所有閘、進了 manifest 的(=分子的候選)
|
||||
unsupported int // 副檔名還讀不了的(docLikeExt 與其他)
|
||||
excluded int // 收檔策略/範本身分決定不收的(程式碼多半落這裡)
|
||||
}
|
||||
|
||||
// MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount
|
||||
@@ -304,6 +329,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數
|
||||
// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
|
||||
var excludedDirs []ExcludedDir
|
||||
// InkStoneCo#44 線 A:逐目錄的分母。與上面幾個計數器同一趟走訪算出來——
|
||||
// 判準只有這一份,畫面上的數字才可能與實際收的檔對得起來。
|
||||
dirStats := map[string]*dirStat{"": {}} // 根一定存在(空資料夾也要有節點,arcrun-rag#106)
|
||||
statOf := func(relSlash string) *dirStat {
|
||||
dir := folderOfRel(relSlash)
|
||||
st, ok := dirStats[dir]
|
||||
if !ok {
|
||||
st = &dirStat{}
|
||||
dirStats[dir] = st
|
||||
}
|
||||
return st
|
||||
}
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
@@ -333,6 +370,11 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// #44 線 A:走得進去的目錄一律登記,**就算它一個檔都沒有**
|
||||
// ——leo 的規格是「不管那層有沒有文件,整棵樹都要採」。
|
||||
if _, ok := dirStats[rel]; !ok {
|
||||
dirStats[rel] = &dirStat{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -342,6 +384,12 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
}
|
||||
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
|
||||
excludedByPlan++
|
||||
// #44 線 A:這是「使用者看得到、但我們決定不收」的檔(程式碼多半落在這裡)。
|
||||
// 它**要算進分母**——leo 的規格就是「兩個數字不相等是正常的,差額=不支援」,
|
||||
// 而分母裡沒有它,差額就永遠解釋不了。
|
||||
s := statOf(rel)
|
||||
s.total++
|
||||
s.excluded++
|
||||
return nil
|
||||
}
|
||||
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
|
||||
@@ -365,6 +413,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
}
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
// #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔——上面三道
|
||||
// (隱藏檔/SkipPaths 的機器檔/TemplateOwns 我們自己鋪的範本)刻意不算進分母,
|
||||
// 理由與 scan.go 既有註解同一條:**我們自己鋪的東西不該佔用他的注意力**。
|
||||
if rel, ok := relOf(); ok {
|
||||
s := statOf(rel)
|
||||
s.total++
|
||||
if allowedExt[ext] {
|
||||
s.supported++
|
||||
} else {
|
||||
s.unsupported++
|
||||
}
|
||||
}
|
||||
if !allowedExt[ext] {
|
||||
// G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
|
||||
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
|
||||
@@ -556,6 +616,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
|
||||
}
|
||||
@@ -574,6 +643,10 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// 排序=畫面每輪穩定;裁切前先記總數,不然「等 N 個」會少報。
|
||||
sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path })
|
||||
excludedDirCount := len(excludedDirs)
|
||||
// #44 線 A:樹要畫得完整,需要**未裁切**的全量(下面那個裁切是給狀態列用的)。
|
||||
// 先複製再裁切——共用同一個底層陣列的話,裁切會讓樹跟著少一截。
|
||||
allExcludedDirs := make([]ExcludedDir, len(excludedDirs))
|
||||
copy(allExcludedDirs, excludedDirs)
|
||||
if len(excludedDirs) > MaxExcludedDirsListed {
|
||||
excludedDirs = excludedDirs[:MaxExcludedDirsListed]
|
||||
}
|
||||
@@ -592,5 +665,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
ExcludedByPlan: excludedByPlan,
|
||||
ExcludedDirs: excludedDirs,
|
||||
ExcludedDirCount: excludedDirCount,
|
||||
DirStats: dirStats,
|
||||
AllExcludedDirs: allExcludedDirs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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 進資料夾,得到的回應是**完全的沉默**。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// triggeroutcome.go — 「雲端收下了」不等於「知識庫寫進去了」(`inkstone/arcrun-rag#104`)。
|
||||
//
|
||||
// 🔴 為什麼有這支檔(2026-08-26 實測,非推測):
|
||||
// 把 leo 真實的 `InkStoneCo` 接上 youlin 跑一輪,daemon 對 26 份檔案蓋了「已送達」章,
|
||||
// **雲端實際只有 4 份**。使用者畫面上是綠的,AR-Mira 一句都查不到。
|
||||
//
|
||||
// 真兇不是萃取、不是排除規則,是這一行:`postJSON` 只看 HTTP 狀態碼。
|
||||
// named-webhook 觸發成功一律回 **200**,而工作流內部有沒有把東西寫進 KBDB
|
||||
// 藏在 body 裡。當天的實際回應(原文照抄):
|
||||
//
|
||||
// HTTP 200
|
||||
// {"success":true,"data":{"success":false,"status":500,
|
||||
// "error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}
|
||||
//
|
||||
// ⇒ 外層說 success,內層說 500/unreachable。daemon 讀外層 ⇒ 蓋章 ⇒
|
||||
// content_hash 沒變就永遠不會重送 ⇒ **這份知識永久消失,而且沒有人會知道**。
|
||||
//
|
||||
// 這與 `Arcrun#135`(push_workflow 對跑不起來的定義回「部署成功!」)是同一個病:
|
||||
// **回報層與事實層分居兩處,而讀的人只讀得到回報層。**
|
||||
// 我們改不了別人回什麼,但可以改「我們信什麼」——2xx 只證明請求送達,
|
||||
// 要證明寫進去了,得看 body。
|
||||
//
|
||||
// 三條自我約束:
|
||||
// - **只在看得懂的時候才判失敗**:body 不是 JSON、或沒有任何 success 欄位
|
||||
// ⇒ 回「看不出來」=照舊當成功。寧可漏判,不可把一次格式變更變成全面停擺。
|
||||
// - **不猜語意**:只認 `success:false` 這個明確訊號,不去猜 data 裡別的欄位。
|
||||
// - **講人話**:訊息會出現在使用者畫面上,不裸露狀態碼與上游 JSON 原文
|
||||
// (同 direct_quota_test.go 那份禁字表)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// triggerEnvelope=named-webhook 觸發端點的回應外殼。
|
||||
// `Data` 用 json.RawMessage:它可能是物件、陣列、字串,甚至 null——
|
||||
// 硬綁成 map 會在形狀一變時整條路 panic 或誤判。
|
||||
type triggerEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// triggerInner=工作流最後一個節點的輸出裡,我們唯一認得的兩個欄位。
|
||||
type triggerInner struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// webhookFailure 檢查「HTTP 2xx 的觸發回應裡,工作流是不是其實失敗了」。
|
||||
//
|
||||
// 回空字串=沒看出失敗(真的成功,或這個回應我們看不懂——兩者都放行)。
|
||||
// 回非空=**確定失敗**,字串是給使用者看的那句話。
|
||||
func webhookFailure(body string) string {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) {
|
||||
return "" // 不是 JSON ⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
var env triggerEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return "" // 解析不了(含被截斷)⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
// ① 外層自己就說失敗
|
||||
if env.Success != nil && !*env.Success {
|
||||
return ingestFailureSentence(env.Error)
|
||||
}
|
||||
// ② 外層說成功,但工作流的輸出說失敗——本檔存在的理由就是這一格
|
||||
if len(env.Data) > 0 {
|
||||
var inner triggerInner
|
||||
if err := json.Unmarshal(env.Data, &inner); err == nil && inner.Success != nil && !*inner.Success {
|
||||
return ingestFailureSentence(inner.Error)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ingestFailureSentence 把上游那串技術文字換成一句使用者讀得懂的話。
|
||||
//
|
||||
// 🔴 不是 debug 訊息,是產品文案:使用者看到「已整理 26 份」卻查不到東西的當下,
|
||||
// 唯一能讓他知道發生什麼事的就是這句(#104 的紅線:不要讓他猜)。
|
||||
// 認不出來的原因不編故事,只誠實說「雲端沒有寫進去」。
|
||||
func ingestFailureSentence(raw string) string {
|
||||
const head = "雲端收下了,但你的知識庫沒有真的寫進去(這一份還查不到)"
|
||||
switch {
|
||||
case strings.Contains(raw, "unreachable"):
|
||||
return head + ":連不到知識庫的資料層。稍後會自動再試。"
|
||||
case strings.Contains(raw, "card_content 為空"):
|
||||
return head + ":這份檔萃出來是空的。"
|
||||
case strings.Contains(raw, "credential"):
|
||||
return head + ":知識庫的內部金鑰不對,要重裝一次雲端才會通。"
|
||||
default:
|
||||
return head + ",稍後會自動再試。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realFailingEnvelope 是 2026-08-26 從 youlin 實例抄回來的**原文**:
|
||||
// HTTP 200、外層 success=true,而工作流其實一個字都沒寫進 KBDB。
|
||||
// 這一份不是虛構的樣本,是這一票的物證——改動這裡等於改動證據。
|
||||
const realFailingEnvelope = `{"success":true,"data":{"success":false,"status":500,` +
|
||||
`"error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}`
|
||||
|
||||
func TestWebhookFailure_實測那份回應必須被判成失敗(t *testing.T) {
|
||||
msg := webhookFailure(realFailingEnvelope)
|
||||
if msg == "" {
|
||||
t.Fatal("外層 success=true、內層 success=false ⇒ 必須判失敗,否則就是 2026-08-26 那個假綠")
|
||||
}
|
||||
if !strings.Contains(msg, "沒有真的寫進去") {
|
||||
t.Errorf("訊息要講出「沒寫進去」這件事:%q", msg)
|
||||
}
|
||||
// 使用者看得到這句話 ⇒ 不准出現狀態碼與上游 JSON 原文(同 direct_quota_test.go 的禁字表)
|
||||
for _, bad := range []string{"500", "HTTP", "unreachable", "{"} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("訊息裸露了技術細節 %q:%q", bad, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookFailure_只在看得懂的時候才判失敗(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
fail bool
|
||||
}{
|
||||
{"一切正常", `{"success":true,"data":{"success":true,"ok":1},"duration_ms":9}`, false},
|
||||
{"外層就說失敗", `{"success":false,"error":"workflow not found"}`, true},
|
||||
{"內層說失敗", realFailingEnvelope, true},
|
||||
{"data 沒有 success 欄位=看不出來", `{"success":true,"data":{"written":3}}`, false},
|
||||
{"data 是陣列", `{"success":true,"data":[1,2,3]}`, false},
|
||||
{"data 是字串", `{"success":true,"data":"done"}`, false},
|
||||
{"data 是 null", `{"success":true,"data":null}`, false},
|
||||
{"根本不是 JSON", `OK`, false},
|
||||
{"空回應", ``, false},
|
||||
{"被截斷的 JSON", `{"success":true,"data":{"success":false,"err`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := webhookFailure(c.body) != ""
|
||||
if got != c.fail {
|
||||
t.Errorf("%s:判失敗=%v,預期 %v(body=%s)", c.name, got, c.fail, c.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 這支是本票的核心迴歸閘:**「已送達」這個章,只能在東西真的寫進去時才准蓋。**
|
||||
// 沒有它,同一個假綠會以任何一種新的包裝再回來一次
|
||||
//(2026-08-26 實錄:26 份蓋章、雲端 4 份,而畫面全綠)。
|
||||
func TestWiring_雲端說沒寫進去就不准蓋已送達的章(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var triggers int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
triggers++
|
||||
// 就是實例當天回的那一份:200 + 外層綠、內層紅
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, realFailingEnvelope)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
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 triggers == 0 {
|
||||
t.Fatal("測試沒打到觸發端點,這支測試等於沒驗到東西")
|
||||
}
|
||||
if exit == 0 {
|
||||
t.Errorf("一份都沒真的寫進去,exit 不該是 0:%+v", results)
|
||||
}
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("那份檔應該是 failed,不是「已送達」:%+v", fileResults)
|
||||
}
|
||||
if !strings.Contains(fileResults[0].Error, "沒有真的寫進去") {
|
||||
t.Errorf("失敗理由要講人話:%q", fileResults[0].Error)
|
||||
}
|
||||
// 最關鍵的一格:manifest 不准留下「已送達」的章——留了,content_hash 沒變就永遠不會重送。
|
||||
m, err := LoadManifest(manifestPath, root)
|
||||
if err != nil {
|
||||
t.Fatalf("讀 manifest:%v", err)
|
||||
}
|
||||
for rel, e := range m.Entries {
|
||||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||||
t.Errorf("%s 蓋了「已送達」章,但雲端根本沒收到 ⇒ 這份知識會永久消失", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 太大的檔:明知送出去一定失敗,就不要送——而且理由要是人話。
|
||||
func TestTooBigForWorkersAI_講人話且不送出去(t *testing.T) {
|
||||
if why := tooBigForWorkersAI(strings.Repeat("a", 1000), "小檔.md"); why != "" {
|
||||
t.Errorf("一般大小的檔不該被擋:%q", why)
|
||||
}
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes) // 中文一字 3 bytes ⇒ 一定超過
|
||||
why := tooBigForWorkersAI(big, "mistakes.md")
|
||||
if why == "" {
|
||||
t.Fatal("超過上限的檔要被擋下")
|
||||
}
|
||||
if !strings.Contains(why, "萬字") || !strings.Contains(why, "拆成") {
|
||||
t.Errorf("要講出多大、以及使用者能做什麼:%q", why)
|
||||
}
|
||||
for _, bad := range []string{"token", "131000", "HTTP", "8007", "llama"} {
|
||||
if strings.Contains(why, bad) {
|
||||
t.Errorf("訊息裸露技術細節 %q:%q", bad, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes)
|
||||
if err := os.WriteFile(filepath.Join(root, "巨檔.md"), []byte("# 巨檔\n\n"+big), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": "{}"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md")
|
||||
if err == nil {
|
||||
t.Fatal("太大的檔應該直接失敗")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("擋下的檔不該還打雲端一次(燒額度+佔佇列),實際打了 %d 次", hits)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "太大") {
|
||||
t.Errorf("理由要講人話:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user