Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bce422f6fe | |||
| 30a087c14d | |||
| 3cc8788e9d |
@@ -47,6 +47,42 @@
|
||||
|
||||
---
|
||||
|
||||
## 0.18.52(2026-09-13)
|
||||
|
||||
- 🔴 **雲端資料庫的免費額度用完時,小幫手會直接告訴你**:以前 Cloudflare 免費帳號當天的資料庫讀取(或寫入)額度用完,小幫手只會顯示看不懂的錯誤(例如「缺少 credential」)或「連續失敗、已暫停自動重試」,看起來像東西壞了。現在首頁會出現一張卡,寫明是哪一種額度用完、免費方案的上限是多少(讀取每天 500 萬列、寫入每天 10 萬列)、台北時間幾點恢復(早上 8:00),以及恢復後會自動接著傳、你不用做任何事。
|
||||
- **額度用完期間不再重打雲端**:在額度恢復之前,小幫手不會再送任何東西上雲端(連「立刻同步」也不會白打),不會把情況弄得更糟;恢復後下一次檢查就自動接上。
|
||||
- 急著用的話,卡片上也會告訴你:升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限。
|
||||
|
||||
## 0.18.51(2026-09-13)
|
||||
|
||||
- 🔴 **雲端出錯時,小幫手不會再一直重打把你的額度燒光**:以前只要你的知識庫某個功能壞掉(例如收筆記或 AI 整理文件那一步一直回錯誤),小幫手每幾秒就把同一件事重打一次,每一次都在消耗雲端的資料庫讀取或 AI 額度,免費帳號一小時就可能被用光。現在同一件事連續失敗幾次,小幫手會先停下來,隔 1 分鐘、2 分鐘、5 分鐘……最長 30 分鐘再試一次;雲端一修好就自動接上,不用重開。
|
||||
- **停下來的時候會告訴你原因**:面板上會寫「雲端某個功能連續失敗,先停幾分鐘再試」,不會看起來像當掉。
|
||||
- **送不上去的文件不會先白白整理一次**:雲端那一步壞著的時候,小幫手不再先花 AI 額度整理文件、再撞同一面牆。
|
||||
- 按「立刻同步」一樣會馬上再試一次。
|
||||
|
||||
## 0.18.50(2026-09-07)
|
||||
|
||||
- 🔴 **Logseq 的備份資料夾不再被當成新知識**:以前 Logseq 每次自動存檔留在 `logseq/bak/` 裡的舊版本,小幫手全都當成一份新文件收進知識庫——一頁筆記改十次就多十份殘影,問 AI 會回一堆一模一樣的舊東西。現在 `logseq/` 這整個資料夾(設定、備份、版本檔)一律不收,你的筆記本體在 pages 與 journals 裡照收不誤;面板上會寫明「這是 Logseq 自己的設定與備份資料夾」。
|
||||
- **已經收進去的殘影會自動從知識庫撤掉**:更新後第一次同步,小幫手會把先前收過、現在依規則不再收的檔一批一批下架(每輪幾十份,慢慢清,不會把雲端打爆),並在面板上告訴你「有幾份因為什麼理由下架」。這種「我們自己決定不收」的情況不會再被「檔案大量消失」的保護機制擋住——那道保護只管真的不見的檔。
|
||||
- **消失的資料夾,雲端那張資料夾索引卡也會一起撤**:以前只清本機的記帳,雲端會永遠留著一張指向不存在資料夾的卡。
|
||||
|
||||
## 0.18.49(2026-08-29)
|
||||
|
||||
- **雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的**:以前小幫手回報資料夾結構時沒有附上自己是誰,所以你在雲端看到的每一個資料夾都掛在「未知來源」底下——幾年下來累積的資料夾全擠在同一個位置,看起來就像別人的東西。現在每一次回報都帶著這台電腦的名字(你在設定裡取過名就用你取的)。
|
||||
- 改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去。
|
||||
- ⚠️ 這一格要雲端也換新了才看得到;小幫手這邊先把名字送出去。收什麼檔、怎麼整理完全沒改。
|
||||
|
||||
## 0.18.48(2026-08-28)
|
||||
|
||||
- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。
|
||||
- **打勾是真的**:以前只要沒有錯誤訊息就顯示「自動同步中」,連空資料夾、連小幫手還沒回報的資料夾都一樣。現在那個記號直接對應「這個資料夾裡幾份進去了/總共幾份」,數字對不上就不會給你打勾。
|
||||
- **一次連線失敗不再讓知識庫版本消失**:以前只要有一輪沒問到,畫面就說「目前連不上這個知識庫,查不到版本」——即使它下一秒就通了。現在問不到只影響「有沒有新版可更新」,你原本的版本號還在。
|
||||
|
||||
|
||||
## 0.18.47(2026-08-28)
|
||||
|
||||
- 這一版你不會看到任何不一樣:收檔、整理、同步、畫面全部跟上一版相同,不必特地更新。改的是我們自己出貨前的自我檢查。以前每次重新編譯程式,就會多出兩顆二十幾 MB 的編譯結果混進原始碼裡,害那道檢查誤判成「程式又被改過了」而擋下出貨——實際上一行程式都沒動。現在編譯出來的東西不再混進去,那道檢查量到的就真的只有程式本身。
|
||||
|
||||
## 0.18.46(2026-08-28)
|
||||
|
||||
- 修好上一版帶進來的問題:雲端只是回得慢,卻被講成「沒有回應」,還害後面的檔被跳過。上一版我替每一種請求設了時間上限,其中「送出一份筆記」給了 60 秒——但實際量出來它本來就要 33 到 57 秒。結果尾巴被剪掉,兩份檔判成失敗,接著整個帳號被判定沒回應,**後面 9 份檔一份都沒送**。這一版把上限依實測值放寬(送知識的請求 5 分鐘、只登記結構的 3 分鐘),並且只有在**連續**等不到回覆時才會暫停該帳號——中間只要成功過一次就重新計算。
|
||||
|
||||
+9
-1
@@ -77,12 +77,20 @@ func fetchBundleVersion(cypherURL string) (version string, ok bool) {
|
||||
return "", false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 16KB:#197 起還要讀 data_layer 區塊(youlin 1.4.63 實測整份約 1.3KB,留足餘裕)。
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var payload struct {
|
||||
BundleVersion string `json:"bundle_version"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&payload); err != nil {
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
// arcrun-rag#197:同一份回應順便看「雲端資料庫額度是不是用完了」(見 cloudquota.go)。
|
||||
// 只有拿到看得懂的回應才更新——連不上時保留上一次親眼看到的狀態,重置時間一到自然失效。
|
||||
noteD1Quota(cypherURL, d1QuotaFromHealth(raw), directNow())
|
||||
return payload.BundleVersion, true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// cloudcheck.go — 每輪固定要問雲端的兩件事(版本、雲端 AI 通了沒),限一分鐘問一次
|
||||
// (`inkstone/arcrun-rag#121`,2026-09-13 真機量測抓到的副作用)。
|
||||
//
|
||||
// 病:這兩發原本「每輪各打一次」。以前一輪要跑十幾秒到幾分鐘(逐檔去撞壞掉的雲端),
|
||||
// 所以每小時大約幾百發。路由退避生效之後,壞掉的那條路不再被打,一輪變成五、六秒就跑完
|
||||
// ⇒ **這兩發變成每 5 秒一次**。實測 geek6688 真雲端 3 分鐘:
|
||||
// 退避版 80 發(其中 /health 36、空探測 36),修前只有 34 發——
|
||||
// 壞掉那條路的請求變少了,雲端收到的總請求卻變多。
|
||||
//
|
||||
// 解:兩件事都是「狀態」,不是「工作」——一分鐘內再問一次得到的答案幾乎一定一樣。
|
||||
// ⇒ 同一台知識庫一分鐘內只真的問一次,其餘沿用上一次的答案。
|
||||
//
|
||||
// 使用者按「立刻同步」的那一輪照問(他要的就是最新狀況)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cloudCheckInterval=同一台知識庫多久才真的重問一次版本/雲端 AI 狀態。
|
||||
var cloudCheckInterval = 60 * time.Second
|
||||
|
||||
type cachedVersion struct {
|
||||
at time.Time
|
||||
ver string
|
||||
ok bool
|
||||
}
|
||||
|
||||
type cachedAIState struct {
|
||||
at time.Time
|
||||
state CloudAIState
|
||||
}
|
||||
|
||||
var (
|
||||
cloudCheckMu sync.Mutex
|
||||
cloudVersionSeen = map[string]cachedVersion{}
|
||||
cloudAISeen = map[string]cachedAIState{}
|
||||
)
|
||||
|
||||
// resetCloudChecks 清空快取(測試用)。
|
||||
func resetCloudChecks() {
|
||||
cloudCheckMu.Lock()
|
||||
defer cloudCheckMu.Unlock()
|
||||
cloudVersionSeen = map[string]cachedVersion{}
|
||||
cloudAISeen = map[string]cachedAIState{}
|
||||
}
|
||||
|
||||
func cloudCheckKey(cypherURL string) string {
|
||||
return strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
|
||||
}
|
||||
|
||||
// cloudVersionThrottled=fetchCloudVersion,但同一台一分鐘內只真的問一次。force=立刻同步。
|
||||
func cloudVersionThrottled(cypherURL string, force bool) (string, bool) {
|
||||
key := cloudCheckKey(cypherURL)
|
||||
now := directNow()
|
||||
cloudCheckMu.Lock()
|
||||
c, hit := cloudVersionSeen[key]
|
||||
cloudCheckMu.Unlock()
|
||||
if hit && !force && now.Sub(c.at) < cloudCheckInterval && !now.Before(c.at) {
|
||||
return c.ver, c.ok
|
||||
}
|
||||
ver, ok := fetchCloudVersion(cypherURL)
|
||||
cloudCheckMu.Lock()
|
||||
cloudVersionSeen[key] = cachedVersion{at: now, ver: ver, ok: ok}
|
||||
cloudCheckMu.Unlock()
|
||||
return ver, ok
|
||||
}
|
||||
|
||||
// cachedAI 回上一次(一分鐘內)問到的雲端 AI 狀態;沒有就 ok=false。
|
||||
func cachedAI(cypherURL string, force bool) (CloudAIState, bool) {
|
||||
if force {
|
||||
return CloudAIState{}, false
|
||||
}
|
||||
now := directNow()
|
||||
cloudCheckMu.Lock()
|
||||
defer cloudCheckMu.Unlock()
|
||||
c, hit := cloudAISeen[cloudCheckKey(cypherURL)]
|
||||
if !hit || now.Sub(c.at) >= cloudCheckInterval || now.Before(c.at) {
|
||||
return CloudAIState{}, false
|
||||
}
|
||||
return c.state, true
|
||||
}
|
||||
|
||||
func rememberAI(cypherURL string, st CloudAIState) {
|
||||
cloudCheckMu.Lock()
|
||||
defer cloudCheckMu.Unlock()
|
||||
cloudAISeen[cloudCheckKey(cypherURL)] = cachedAIState{at: directNow(), state: st}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// cloudcheck_test.go — 每輪固定問雲端的兩件事,一分鐘只真的問一次(inkstone/arcrun-rag#121)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// init:測試環境預設不快取——既有測試(例如「第一輪查得到版本、第二輪 DNS 抽風」)
|
||||
// 是連續兩輪背靠背跑,模擬的是「每一輪都真的問過」。
|
||||
// 需要驗證節流本身的測試(下方與 route_backoff_measure_test.go)自行 save/restore 成真值。
|
||||
func init() {
|
||||
cloudCheckInterval = 0
|
||||
}
|
||||
|
||||
func withCloudCheckInterval(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
old := cloudCheckInterval
|
||||
cloudCheckInterval = d
|
||||
resetCloudChecks()
|
||||
t.Cleanup(func() {
|
||||
cloudCheckInterval = old
|
||||
resetCloudChecks()
|
||||
})
|
||||
}
|
||||
|
||||
func withClock(t *testing.T, start time.Time) *atomic.Int64 {
|
||||
t.Helper()
|
||||
var ns atomic.Int64
|
||||
ns.Store(start.UnixNano())
|
||||
old := directNow
|
||||
directNow = func() time.Time { return time.Unix(0, ns.Load()).UTC() }
|
||||
t.Cleanup(func() { directNow = old })
|
||||
return &ns
|
||||
}
|
||||
|
||||
func TestCloudVersion_AskedOncePerMinute(t *testing.T) {
|
||||
withCloudCheckInterval(t, 60*time.Second)
|
||||
t0 := time.Date(2026, 9, 13, 5, 0, 0, 0, time.UTC)
|
||||
clock := withClock(t, t0)
|
||||
|
||||
var calls int
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { calls++; return "1.4.60", true }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
u := "https://a.workers.dev"
|
||||
for i := 0; i < 12; i++ { // 一分鐘內每 5 秒一輪
|
||||
clock.Store(t0.Add(time.Duration(i*5) * time.Second).UnixNano())
|
||||
if v, ok := cloudVersionThrottled(u, false); v != "1.4.60" || !ok {
|
||||
t.Fatalf("快取回的答案要與真問到的一樣:%q %v", v, ok)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("一分鐘內 12 輪應該只真的問 1 次,got %d", calls)
|
||||
}
|
||||
clock.Store(t0.Add(60 * time.Second).UnixNano())
|
||||
cloudVersionThrottled(u, false)
|
||||
if calls != 2 {
|
||||
t.Fatalf("滿一分鐘要重問,got %d", calls)
|
||||
}
|
||||
cloudVersionThrottled(u, true)
|
||||
if calls != 3 {
|
||||
t.Fatalf("按「立刻同步」的那一輪要照問,got %d", calls)
|
||||
}
|
||||
cloudVersionThrottled("https://b.workers.dev", false)
|
||||
if calls != 4 {
|
||||
t.Fatalf("不同知識庫各問各的,got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeWorkersAI_AskedOncePerMinute(t *testing.T) {
|
||||
withCloudCheckInterval(t, 60*time.Second)
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
t0 := time.Date(2026, 9, 13, 5, 0, 0, 0, time.UTC)
|
||||
clock := withClock(t, t0)
|
||||
|
||||
var probes atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/extract") {
|
||||
probes.Add(1)
|
||||
w.WriteHeader(http.StatusBadRequest) // 空 text 的探測:路由存在=可用
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "k"}
|
||||
for i := 0; i < 12; i++ {
|
||||
clock.Store(t0.Add(time.Duration(i*5) * time.Second).UnixNano())
|
||||
if st := cfg.probeWorkersAI(); !st.Ready {
|
||||
t.Fatalf("第 %d 輪:探測結果要沿用「可用」,got %+v", i, st)
|
||||
}
|
||||
}
|
||||
if n := probes.Load(); n != 1 {
|
||||
t.Fatalf("一分鐘內 12 輪應該只探測 1 次,got %d", n)
|
||||
}
|
||||
clock.Store(t0.Add(61 * time.Second).UnixNano())
|
||||
cfg.probeWorkersAI()
|
||||
if n := probes.Load(); n != 2 {
|
||||
t.Fatalf("滿一分鐘要重新探測,got %d", n)
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
// cloudquota.go — 雲端資料庫(Cloudflare D1)每日免費額度用完時,講明白、且不再重打
|
||||
// (`inkstone/arcrun-rag#197`,母票 `inkstone/InkStoneCo#132`)。
|
||||
//
|
||||
// 病(2026-09-13 實撞,youlin 與 geek6688 兩台免費帳號同一天):
|
||||
// D1 當日讀取額度用完,雲端每一件事都失敗。使用者在小幫手上看到的是
|
||||
// 「缺少 credential: kbdb_internal_token」(假的,`inkstone/Arcrun#216` c7066 已查證)
|
||||
// 與「連續失敗 N 次,已暫停自動重試」——**沒有任何一處說「額度用完了」**。
|
||||
//
|
||||
// 而雲端其實早就知道:cypher `/health` 的 `data_layer.probe_error` 當下寫著
|
||||
//
|
||||
// D1_ERROR: Your account has exceeded D1's free tier daily row read limit.
|
||||
// Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue.
|
||||
//
|
||||
// 小幫手每分鐘本來就在打這支 `/health`(cloudcheck.go),只是只讀了版本號。
|
||||
// ⇒ 本檔把「知道的地方」接到「用戶看得到的地方」,並且在額度恢復前**整個帳號不再打雲端寫入**
|
||||
// (恢復前每一發都只會拿到同一個錯,還會讓免費帳號的其他額度一起被燒)。
|
||||
//
|
||||
// 🔴 用量數字:Cloudflare 只在帳號層的 GraphQL 分析 API 給得出「今天用了幾列」,
|
||||
// 而小幫手身上沒有任何 Cloudflare 金鑰,安裝器要的授權範圍也沒有分析讀取權限
|
||||
// (installer/oauth-prototype/worker.js `OAUTH_SCOPES`)。所以這一版**不假裝查得到**:
|
||||
// 上限是官方公開的固定值(developers.cloudflare.com/d1/platform/pricing/,
|
||||
// Workers Free:每天讀 500 萬列、寫 10 萬列,00:00 UTC 重置),
|
||||
// 而「已經用到上限」是錯誤訊息本身證明的事實——兩句都是真的,不必查。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// D1 免費方案每日上限(Cloudflare 官方定價頁,2026-09-13 查)。
|
||||
const (
|
||||
d1FreeRowsReadPerDay = 5_000_000
|
||||
d1FreeRowsWrittenPerDay = 100_000
|
||||
)
|
||||
|
||||
// QuotaNotice.Kind 的值。空字串=舊的 Workers AI 額度(向後相容 status.json)。
|
||||
const (
|
||||
QuotaKindD1Read = "d1_read"
|
||||
QuotaKindD1Write = "d1_write"
|
||||
)
|
||||
|
||||
// d1QuotaKind 從任何一段上游文字認出「D1 每日免費額度用完」,回 read/write/""。
|
||||
// 只認 Cloudflare 錯誤原文裡的固定片段——「D1_ERROR」單獨出現不算(別的 D1 錯誤也帶它)。
|
||||
func d1QuotaKind(text string) string {
|
||||
t := strings.ToLower(text)
|
||||
if !strings.Contains(t, "free tier") && !strings.Contains(t, "daily") {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(t, "row read limit"), strings.Contains(t, "rows read"):
|
||||
return QuotaKindD1Read
|
||||
case strings.Contains(t, "row write limit"), strings.Contains(t, "rows written"), strings.Contains(t, "row written"):
|
||||
return QuotaKindD1Write
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// d1QuotaFromHealth 讀 cypher `/health` 的回應,回它回報的 D1 額度狀況(""=沒有)。
|
||||
// 舊版雲端(沒有 data_layer 區塊,例如 1.4.46)一律回 ""——認不出來就不編故事。
|
||||
func d1QuotaFromHealth(body []byte) string {
|
||||
var payload struct {
|
||||
DataLayer *struct {
|
||||
OK bool `json:"ok"`
|
||||
ProbeError string `json:"probe_error"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"data_layer"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.DataLayer == nil || payload.DataLayer.OK {
|
||||
return ""
|
||||
}
|
||||
if k := d1QuotaKind(payload.DataLayer.ProbeError); k != "" {
|
||||
return k
|
||||
}
|
||||
return d1QuotaKind(payload.DataLayer.Summary)
|
||||
}
|
||||
|
||||
type d1QuotaState struct {
|
||||
kind string
|
||||
seen time.Time // 最後一次親眼看到額度用完的時間
|
||||
until time.Time // 下一次 00:00 UTC——過了就一定不再相信這筆紀錄
|
||||
}
|
||||
|
||||
var (
|
||||
d1QuotaMu sync.Mutex
|
||||
d1QuotaSeen = map[string]d1QuotaState{} // key=cloudCheckKey(cypherURL)
|
||||
)
|
||||
|
||||
func resetD1Quota() {
|
||||
d1QuotaMu.Lock()
|
||||
defer d1QuotaMu.Unlock()
|
||||
d1QuotaSeen = map[string]d1QuotaState{}
|
||||
}
|
||||
|
||||
// noteD1Quota 記下「這台知識庫此刻的 D1 額度狀況」。kind==""=雲端親口說資料層正常 ⇒ 清掉。
|
||||
func noteD1Quota(cypherURL, kind string, now time.Time) {
|
||||
key := cloudCheckKey(cypherURL)
|
||||
d1QuotaMu.Lock()
|
||||
defer d1QuotaMu.Unlock()
|
||||
if kind == "" {
|
||||
delete(d1QuotaSeen, key)
|
||||
return
|
||||
}
|
||||
d1QuotaSeen[key] = d1QuotaState{kind: kind, seen: now, until: nextQuotaResetTaiwan(now)}
|
||||
}
|
||||
|
||||
// activeD1Quota 回這台知識庫現在是否處於 D1 額度用完(過了重置時間的舊紀錄一律丟掉)。
|
||||
func activeD1Quota(cypherURL string, now time.Time) (d1QuotaState, bool) {
|
||||
key := cloudCheckKey(cypherURL)
|
||||
d1QuotaMu.Lock()
|
||||
defer d1QuotaMu.Unlock()
|
||||
st, ok := d1QuotaSeen[key]
|
||||
if !ok {
|
||||
return d1QuotaState{}, false
|
||||
}
|
||||
if !now.Before(st.until) {
|
||||
delete(d1QuotaSeen, key)
|
||||
return d1QuotaState{}, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
// buildD1QuotaNotice 組出給用戶看的話。欄位沿用 QuotaNotice(App 首頁那張卡、托盤狀態列都吃它)。
|
||||
//
|
||||
// 用戶要知道的四件事(票上的驗收條件):哪一種額度/用了多少、上限多少/台北幾點恢復/恢復後要不要做事。
|
||||
func buildD1QuotaNotice(kind string, now, resetAt time.Time) QuotaNotice {
|
||||
what, limit := "讀取", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsReadPerDay))
|
||||
if kind == QuotaKindD1Write {
|
||||
what, limit = "寫入", fmt.Sprintf("每天 %s列", humanRows(d1FreeRowsWrittenPerDay))
|
||||
}
|
||||
return QuotaNotice{
|
||||
Kind: kind,
|
||||
Headline: fmt.Sprintf("你的雲端知識庫今天的免費%s額度用完了", what),
|
||||
Usage: fmt.Sprintf("Cloudflare 免費方案的資料庫%s上限是%s,今天已經用到上限(這不是小幫手或你的檔案壞掉)", what, limit),
|
||||
Achievement: fmt.Sprintf("雲端資料庫今天的免費%s額度用完了", what),
|
||||
ExitOptions: "升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限",
|
||||
Guarantee: fmt.Sprintf("台北時間%s早上 8:00 恢復,恢復後小幫手會自動接著傳,你不用做任何事",
|
||||
quotaResetDayWord(now, resetAt)),
|
||||
ResumeAt: resetAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func humanRows(n int) string {
|
||||
if n >= 10_000 && n%10_000 == 0 {
|
||||
return fmt.Sprintf("%d 萬", n/10_000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// d1QuotaNote=「這一發不打,因為雲端資料庫額度用完」的一句話;空=可以打。
|
||||
//
|
||||
// 🔴 結尾「會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go)——被擋下的檔
|
||||
// 要帶著這句話出現在畫面上,不能安靜消失。
|
||||
func d1QuotaNote(cypherURL string, now time.Time) string {
|
||||
st, ok := activeD1Quota(cypherURL, now)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
n := buildD1QuotaNotice(st.kind, now, st.until)
|
||||
return n.Headline + ",先不送,避免白白重打;" + n.Guarantee + "(會自動恢復)。"
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package collector
|
||||
|
||||
// cloudquota_test.go — arcrun-rag#197:雲端資料庫(D1)免費額度用完時,
|
||||
// ① 認得出來 ② 整個帳號不再打雲端 ③ 用戶看得到白話 ④ 恢復後自己接上。
|
||||
//
|
||||
// /health 的回應原文照抄 2026-09-13 20:3x 對 youlin(1.4.63)實打的結果,不是編的。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const youlinHealthD1ReadExhausted = `{"ok":true,"status":"degraded","data_layer":{"ok":false,"summary":"資料層探測失敗——連 schema 都讀不到(D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details.)。這台的知識庫現在不可用。","expected_generation":9,"actual_generation":-1,"behind_by":-1,"missing":[],"legacy_tables":[],"remedy":"確認 D1 binding(DB)指向正確的 arcrun-kbdb,並重跑安裝器或 ` + "`acr update`" + `。","probe_error":"D1_ERROR: Your account has exceeded D1's free tier daily row read limit. Upgrade to a paid plan or wait until tomorrow (midnight UTC) to continue. See https://developers.cloudflare.com/d1/platform/limits/ for more details."},"bundle_version":"1.4.63","bundle_commit":"be6bff7cf10a","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}`
|
||||
|
||||
// geek6688 同一時間(1.4.46,沒有 data_layer)的原文:認不出來就不編故事。
|
||||
const geekHealthOld = `{"ok":true,"bundle_version":"1.4.46","auth_store":{"console":{"home":"sessions-kv","writable":true,"legacy_secrets_present":false},"portal_users":{"home":"kbdb","writable":true,"legacy_secrets_present":false}},"mail_relay_configured":true}`
|
||||
|
||||
const healthyNew = `{"ok":true,"status":"ok","data_layer":{"ok":true,"summary":"ok"},"bundle_version":"1.4.64"}`
|
||||
|
||||
func TestD1QuotaKind(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"D1_ERROR: Your account has exceeded D1's free tier daily row read limit.": QuotaKindD1Read,
|
||||
"D1_ERROR: Your account has exceeded D1's free tier daily row write limit.": QuotaKindD1Write,
|
||||
"D1_ERROR: no such table: entries": "",
|
||||
"4006: you have used up your daily free allocation of 10,000 neurons": "", // Workers AI,不是 D1
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := d1QuotaKind(in); got != want {
|
||||
t.Errorf("d1QuotaKind(%q)=%q,要 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := d1QuotaFromHealth([]byte(youlinHealthD1ReadExhausted)); got != QuotaKindD1Read {
|
||||
t.Fatalf("youlin 實打的 /health 應認出讀取額度用完,got %q", got)
|
||||
}
|
||||
if got := d1QuotaFromHealth([]byte(geekHealthOld)); got != "" {
|
||||
t.Fatalf("舊版雲端沒有 data_layer,不准猜,got %q", got)
|
||||
}
|
||||
if got := d1QuotaFromHealth([]byte(healthyNew)); got != "" {
|
||||
t.Fatalf("資料層正常不准報額度,got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestD1QuotaNotice_TellsUserEverything(t *testing.T) {
|
||||
// 台北 2026-09-13 20:30 = UTC 12:30 ⇒ 下一次重置是台北 09-14 08:00(明天)
|
||||
now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC)
|
||||
n := buildD1QuotaNotice(QuotaKindD1Read, now, nextQuotaResetTaiwan(now))
|
||||
all := n.Headline + n.Usage + n.Guarantee + n.ExitOptions
|
||||
for _, must := range []string{"讀取", "額度用完", "500 萬", "已經用到上限", "台北時間明天早上 8:00", "自動接著傳", "不用做任何事"} {
|
||||
if !strings.Contains(all, must) {
|
||||
t.Errorf("訊息少了「%s」:%+v", must, n)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"D1_ERROR", "HTTP", "credential", "UTC"} {
|
||||
if strings.Contains(all, banned) {
|
||||
t.Errorf("用戶看得到的字不准出現 %q:%+v", banned, n)
|
||||
}
|
||||
}
|
||||
if n.ResumeAt != "2026-09-14T08:00:00+08:00" {
|
||||
t.Errorf("ResumeAt=%s", n.ResumeAt)
|
||||
}
|
||||
w := buildD1QuotaNotice(QuotaKindD1Write, now, nextQuotaResetTaiwan(now))
|
||||
if !strings.Contains(w.Headline, "寫入") || !strings.Contains(w.Usage, "10 萬") {
|
||||
t.Errorf("寫入額度要講寫入與 10 萬:%+v", w)
|
||||
}
|
||||
// 台北凌晨 02:00(UTC 前一天 18:00)⇒ 重置是「今天」早上 8 點
|
||||
early := time.Date(2026, 9, 13, 18, 0, 0, 0, time.UTC)
|
||||
if e := buildD1QuotaNotice(QuotaKindD1Read, early, nextQuotaResetTaiwan(early)); !strings.Contains(e.Guarantee, "今天早上 8:00") {
|
||||
t.Errorf("凌晨要說今天:%s", e.Guarantee)
|
||||
}
|
||||
// 被擋下的檔要帶著原因上畫面(explainsWhySkipped 認得)
|
||||
resetD1Quota()
|
||||
defer resetD1Quota()
|
||||
noteD1Quota("https://x.example", QuotaKindD1Read, now)
|
||||
if note := d1QuotaNote("https://x.example", now); !explainsWhySkipped(note) {
|
||||
t.Errorf("擋下的訊息畫面認不出原因:%s", note)
|
||||
}
|
||||
}
|
||||
|
||||
// 核心驗收:額度用完期間,整輪同步對雲端**零寫入**;/health 說恢復了就自己接上。
|
||||
func TestD1Quota_NoCloudWritesUntilRecovered(t *testing.T) {
|
||||
resetD1Quota()
|
||||
resetCloudChecks()
|
||||
cloudRoutes.reset()
|
||||
defer func() { resetD1Quota(); resetCloudChecks(); cloudRoutes.reset() }()
|
||||
|
||||
var healthBody atomic.Value
|
||||
healthBody.Store(youlinHealthD1ReadExhausted)
|
||||
var posts, healths int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/health" {
|
||||
atomic.AddInt64(&healths, 1)
|
||||
_, _ = w.Write([]byte(healthBody.Load().(string)))
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&posts, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"success":true}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = fetchBundleVersion // 走真的 /health 解析
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
if _, ok := cloudVersionThrottled(srv.URL, false); !ok {
|
||||
t.Fatal("/health 應可達")
|
||||
}
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"}
|
||||
url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger"
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{"i": i})
|
||||
if err == nil || !isRouteBackoff(err) {
|
||||
t.Fatalf("第 %d 發:額度用完應被擋成『沒打出去』,got %v", i, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "額度用完") {
|
||||
t.Fatalf("擋下的原因要講額度:%v", err)
|
||||
}
|
||||
}
|
||||
// 按「立刻同步」也不打(但會重問 /health)
|
||||
force := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k", ForceSync: true}
|
||||
cloudVersionThrottled(srv.URL, true)
|
||||
if _, _, err := force.postJSON(stepIngestCard, url, map[string]any{}); err == nil {
|
||||
t.Fatal("額度沒恢復時立刻同步也不該打出去")
|
||||
}
|
||||
if got := atomic.LoadInt64(&posts); got != 0 {
|
||||
t.Fatalf("額度用完期間打了 %d 發寫入,要 0", got)
|
||||
}
|
||||
|
||||
// 雲端恢復:下一次強制重問 /health 就放行
|
||||
healthBody.Store(healthyNew)
|
||||
cloudVersionThrottled(srv.URL, true)
|
||||
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err != nil {
|
||||
t.Fatalf("恢復後應自動接上,got %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&posts); got != 1 {
|
||||
t.Fatalf("恢復後應打出 1 發,got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 寫入那一發自己帶回 D1 額度原文(不等下一分鐘的 /health)⇒ 下一發起就停。
|
||||
func TestD1Quota_LearnsFromFailedWrite(t *testing.T) {
|
||||
resetD1Quota()
|
||||
cloudRoutes.reset()
|
||||
defer func() { resetD1Quota(); cloudRoutes.reset() }()
|
||||
var posts int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&posts, 1)
|
||||
w.WriteHeader(500)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"D1_ERROR: Your account has exceeded D1's free tier daily row write limit."}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, Namespace: "ns", APIKey: "k"}
|
||||
url := srv.URL + "/webhooks/named/ns/rag_ingest_card/trigger"
|
||||
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); err == nil || isRouteBackoff(err) {
|
||||
t.Fatalf("第一發應真的打出去並失敗,got %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := cfg.postJSON(stepIngestCard, url, map[string]any{}); !isRouteBackoff(err) {
|
||||
t.Fatalf("認出寫入額度用完後應停打,got %v", err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt64(&posts); got != 1 {
|
||||
t.Fatalf("只該打出 1 發,got %d", got)
|
||||
}
|
||||
if st, ok := activeD1Quota(srv.URL, directNow()); !ok || st.kind != QuotaKindD1Write {
|
||||
t.Fatalf("應記成寫入額度,got %+v %v", st, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 過了重置時間(台北 08:00)舊紀錄一定失效——就算那之後 /health 一次都沒打到。
|
||||
func TestD1Quota_ExpiresAtReset(t *testing.T) {
|
||||
resetD1Quota()
|
||||
defer resetD1Quota()
|
||||
now := time.Date(2026, 9, 13, 12, 30, 0, 0, time.UTC)
|
||||
noteD1Quota("https://x.example", QuotaKindD1Read, now)
|
||||
if _, ok := activeD1Quota("https://x.example", now.Add(11*time.Hour)); !ok {
|
||||
t.Fatal("重置前應仍有效")
|
||||
}
|
||||
if _, ok := activeD1Quota("https://x.example", time.Date(2026, 9, 14, 0, 0, 1, 0, time.UTC)); ok {
|
||||
t.Fatal("過了 00:00 UTC 應失效")
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,17 @@ dist-msix/
|
||||
# Python 匯入快取(daemon-version.py 被 import 時產生)。可重現的產物,且綁 Python 版本
|
||||
# (檔名帶 cpython-314)⇒ 不進版控。2026-08-18 D95 第三輪:第一輪搬檔時誤把一顆帶了進來。
|
||||
__pycache__/
|
||||
|
||||
# 🔴 `go build ./...` 在本目錄留下的執行檔(inkstone/arcrun-rag#158,2026-08-28)。
|
||||
# Go 對「單一 main 套件」會把成品寫成**目錄名**,所以任何人在這裡跑一次 go build
|
||||
# 就會生出 `arcrun-app` 這顆 20 MB 的二進位。它一路被追蹤到 2026-08-28 為止。
|
||||
#
|
||||
# 為什麼非拿掉不可(實測,不是推論):這顆檔案**在版本指紋的涵蓋範圍內**
|
||||
# (`daemon-version.py` 的 `scan_source()` 走 `git ls-files -co --exclude-standard`)
|
||||
# ⇒ 只是重編一次、一行原始碼都沒改,指紋就從 1325784830f592ff 變成 eba122f83317b67d
|
||||
# ⇒ 出貨線的 `daemon-source-check` 判「0.18.46 打包之後,1 個檔案變動過」而擋下。
|
||||
# **把編譯產物算進「原始碼指紋」,那個指紋就量不到它宣稱要量的東西。**
|
||||
#
|
||||
# ⚠️ 錨在本目錄(開頭的 `/`):不要寫成 `arcrun-app`,那會連子目錄裡同名的東西一起蓋掉。
|
||||
# 打包腳本要的產物在 `build/bin/`(早就 ignore),不是這一顆——三支腳本都沒引用它。
|
||||
/arcrun-app
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.46",
|
||||
"fingerprint": "1325784830f592ff",
|
||||
"version": "0.18.52",
|
||||
"fingerprint": "5ab62f966033ea4b",
|
||||
"files": {
|
||||
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
|
||||
"CHANGELOG.md": "0ccd77301d2cc432a93699d90f45d1c012020cc447b0cda6db79655bc928822e",
|
||||
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
|
||||
"CHANGELOG.md": "718ac1789302c16de2af1595b4081a0b0b2eff88ffd007f33f78720b79521c18",
|
||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
||||
@@ -17,14 +17,17 @@
|
||||
"cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a",
|
||||
"cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f",
|
||||
"cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa",
|
||||
"cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9",
|
||||
"cloud_version.go": "00e385e9157720d6744573c114e0b0e42dff1920a73e6ac0b75861650e2d8897",
|
||||
"cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6",
|
||||
"cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8",
|
||||
"cloudcheck.go": "41605d4d16fb9e80ee9b58deec40e10260c3e3f361dd717bd90c2be40dfa153d",
|
||||
"cloudcheck_test.go": "8e3a73934a68d668c34ed5c9571db143985bc481453aa2f1b5f4411e3c4eb1b8",
|
||||
"cloudquota.go": "d19521b5de3e48a8d7b8d10ee0491eca34379c40824f1ffde9a8e3e20489b725",
|
||||
"cloudquota_test.go": "c2ab6ada0b09f2092a07b84e00090bb74dc81f8204906dde8dd4c53cfd24b9c1",
|
||||
"cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37",
|
||||
"cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9",
|
||||
"cmd/arcrun-app/app.go": "274129ed77c4f4194f6b2007cb47e4ffe536b15b6450fe89b14fbb31e6a16395",
|
||||
"cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698",
|
||||
"cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96",
|
||||
"cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c",
|
||||
"cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79",
|
||||
"cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2",
|
||||
"cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1",
|
||||
"cmd/arcrun-app/assets/store/Square310x310Logo.png": "f610d521767347974fcd68006055d06c5366e99c247d31a2b9221bae34d67798",
|
||||
@@ -72,10 +75,12 @@
|
||||
"cmd/arcrun-app/diagnostics_export.go": "19328a384d653a24c557f5ee7497d62f1c1eaeba37976819b07e44f002b8862b",
|
||||
"cmd/arcrun-app/diagnostics_export_test.go": "00fbd5a436a82652d28f9974d29dcbaf57a432ec0df05bf491e6f9755bbff00b",
|
||||
"cmd/arcrun-app/diagnostics_stage_manual_test.go": "e1c9fe6b8d6d6e39c74efec5c8874bf4a7922cf5b03789e49c40ef24231d1f00",
|
||||
"cmd/arcrun-app/dist/Arcrun-v0.18.3.dmg": "bfe9282ca591b62390e53e439a9f97ed71e6c7c7ece2dd61e10b75e77b0ae991",
|
||||
"cmd/arcrun-app/dock_darwin.go": "25f07f57eea6dbacdc4eaf652ad56b83b4641ec6392f6a4a4943b67f3f24156a",
|
||||
"cmd/arcrun-app/dock_other.go": "b5f7bb020ec20c9ed27436b5be15aa16798a180d1bf200b4158c5bdbfe3e6be7",
|
||||
"cmd/arcrun-app/firstrun_test.go": "90d74db3e6a7b77617d50559a181229c0e720c20ead35b9554cb72e5212c7303",
|
||||
"cmd/arcrun-app/folder_badge.go": "ee2b3a62fc7424816268267e573be5b59174d1fcc3a492dded6b9b220506b1a5",
|
||||
"cmd/arcrun-app/folder_badge_fixture_test.go": "38c74b7571f60d24c3d1b337e79f38df153b589f3cd911cfbaf8369e4759ba18",
|
||||
"cmd/arcrun-app/folder_badge_test.go": "61377b6cbf51be32ee5b4fdc40724afd9a040e1041886c96f364d480bdd02801",
|
||||
"cmd/arcrun-app/freshinstall_e2e_test.go": "59d8c8c75365f7821f838baca9abd93f167bebd0998779a54c5fe33e390e7331",
|
||||
"cmd/arcrun-app/frontend/index.html": "3cbf1ec22539e0e0ad7e8e86b1857cf752e042ecfd52e816f7d2bdcd7cf99a91",
|
||||
"cmd/arcrun-app/frontend/package-lock.json": "a4020582cf3fb5f2413451d5d5498a54ceadae153d28e3f59f81f6a7b2c97caa",
|
||||
@@ -90,8 +95,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": "b0d9e4980cdf46a379888542ee266cfd68af7d4cadcb74420ef7db3a26ffffcb",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "2c4ac3017bead3a99812ce8919a513fb0e23dd752285a426c649c6e774d0f08d",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a",
|
||||
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
|
||||
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
|
||||
"cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256",
|
||||
@@ -113,7 +118,6 @@
|
||||
"cmd/arcrun-app/upgrade_repair_test.go": "65413d0b07b6c011de2af2783247d0f602a680a199229b52a77e176626c8ef26",
|
||||
"cmd/arcrun-app/wails.json": "a6448229972e3194026f6a407a427a5601ce6f5deb43d1fb7c5b5226512d8bc5",
|
||||
"cmd/collector/main.go": "fd04291f6cd4833f00593a0580baeaa231875da6d26636fca4f51090f0a80c80",
|
||||
"collector": "ef769ff086a4da691b4bd646de4efb6957204efa829545e43cbcb99bf9503b70",
|
||||
"convert.go": "fb3b52354b94f527ccffa5600620749a5fa6009834530aac04b7d141bb3e3b4b",
|
||||
"convert_docx.go": "5d4f459ff866c86ee78424503e1c9a410e1dbfd0ece40d63a603b94b61b7ddaf",
|
||||
"convert_pdf.go": "e4955008f9bf9e458304fa0baa087ae7a37ac52e9fb66df3e99f22a4c513b9b1",
|
||||
@@ -124,7 +128,7 @@
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||
"direct.go": "1ccb56abf877f75a61c47cbc9d5797739f58dbc9f321de4f29d9022320f2e41e",
|
||||
"direct.go": "3a79d03fce931115739a0113494a61331155172c7f9e5cbee126203b3e616458",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||
@@ -137,15 +141,17 @@
|
||||
"extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4",
|
||||
"extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0",
|
||||
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
|
||||
"extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5",
|
||||
"extract_workersai.go": "b1a2fc568cd843bd4921794a7c6f4178ee81301c9d295ede074c21eee0e6013d",
|
||||
"extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3",
|
||||
"folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089",
|
||||
"folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314",
|
||||
"folderindex.go": "ff69bf38178bfa5af4e659fb43827b72bd9669c259e048d97e5a3c8c6d0f9f29",
|
||||
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
|
||||
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
|
||||
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
|
||||
"foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd",
|
||||
"foldertree.go": "fb86c8d869a476d77d548e9c9a7c14be51c76fa1400a6497016b705ff9c4ce13",
|
||||
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
|
||||
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
|
||||
"foldertree_machine_test.go": "f5dc73afc261b0fb0298f27962628e8538abc704540b52d66e13bf85600a59dd",
|
||||
"foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea",
|
||||
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
|
||||
"foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa",
|
||||
@@ -155,13 +161,14 @@
|
||||
"grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2",
|
||||
"ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14",
|
||||
"ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16",
|
||||
"ingestplan.go": "f32cd7a740fc291a6b54e2804e0bdf0d50770962414fafe92b015ddf0c6fcddf",
|
||||
"ingestplan.go": "ac5351d6061f1db34df4005bf49e45a482a6588c9f7758bfd8a3317e5b34e444",
|
||||
"ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be",
|
||||
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
|
||||
"inventory.go": "707b2b74dccbd082d56793a558bb28ce0f8812d458b221d8b2d6b084b752af50",
|
||||
"inventory.go": "40d79723ed1325e758dd85b3647891d20f4a68e0ae3b5f23fc2a19d99b160383",
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
|
||||
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
|
||||
"logseq_bak_residue_test.go": "52aa2f47ac12ca2e77e6112d6de88c873f1f418bae32b2a4de7a35028f835257",
|
||||
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
|
||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
||||
@@ -171,23 +178,25 @@
|
||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
||||
"probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980",
|
||||
"progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37",
|
||||
"probe_workersai.go": "ce0ceb63377e4b0181bf6acb1c14c5cc0c75173360e0e27392cb9e5b18e2cffc",
|
||||
"progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a",
|
||||
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
||||
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
||||
"quota.go": "034a2a682b66606e9ab531a251e5157109c9a66ddca6e3ffcecc8fcd10dc0aa3",
|
||||
"quota.go": "c6596993a0103b8a22c7148d6f9adeec0da09dceaaba040b201476b920027fa4",
|
||||
"quota_test.go": "a91edeb71bf5f3aaa9bd68ad5ec0c87d1e620acc8072e3114098db4d8fc1a896",
|
||||
"repoguard.go": "bcbfab2b76b8fb6afa67aa30a030931a127bae4d9d4a363c40522457ab192c1b",
|
||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||
"route_backoff_measure_test.go": "d09463f309ec0c7e9c67523717a1962908a38b9d5338edd72e49e151cfd264eb",
|
||||
"routebackoff.go": "09a23fef781c174fe3d257e42da58540bd6971aff7151ffa6fb5188de9188e6f",
|
||||
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
||||
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
||||
"scan.go": "546329b883228687a60d48419c19113a6208f0f5ea18a0d3ba0fc401ab3c0974",
|
||||
"scan.go": "a91ca567cff71864cb5a0a2af234a478df7118c47bfa24727f1b37c3f0466540",
|
||||
"scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575",
|
||||
"scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362",
|
||||
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
|
||||
"scan_skipped_test.go": "983ecabb5d93388a501836d15dd445789b7f3028a7f3b966e04425ad4d5bfd9c",
|
||||
"scan_test.go": "d0e472686ff41da2421bfd45b63099fd75877e6fc72f26dfdd1a07dc64c9f0cd",
|
||||
"sourcerepair.go": "bdb1a4b64d028a9df42a7b9258a431e01f1be8871c490b92011d0c6ee5e53b79",
|
||||
"sourcerepair.go": "59fa1956181904f6661ab397e42b4f1c9b8ac81f382fdf720cc5b0831390f9d9",
|
||||
"sourcerepair_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
|
||||
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
|
||||
"stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6",
|
||||
@@ -196,7 +205,7 @@
|
||||
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
|
||||
"supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
|
||||
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
||||
"sync_status.go": "a4c2b10de55b0a01e2c723946812c7eaa59a6ff0c0d2d3c8512b37935f4e205e",
|
||||
"sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f",
|
||||
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
|
||||
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
|
||||
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
|
||||
|
||||
@@ -16,5 +16,11 @@
|
||||
"0.18.43": "34f17e1e6a190eaa",
|
||||
"0.18.44": "19e26497ae609682",
|
||||
"0.18.45": "dc05513103f93306",
|
||||
"0.18.46": "1325784830f592ff"
|
||||
"0.18.46": "1325784830f592ff",
|
||||
"0.18.47": "0decb0810da46a3f",
|
||||
"0.18.48": "a440a58a629f960c",
|
||||
"0.18.49": "7cb86e646aeaa057",
|
||||
"0.18.50": "b30716c3ccec9e65",
|
||||
"0.18.51": "3cfcc815cf185a68",
|
||||
"0.18.52": "5ab62f966033ea4b"
|
||||
}
|
||||
|
||||
+28
-2
@@ -122,6 +122,10 @@ type syncStatus struct {
|
||||
// arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
|
||||
// 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。
|
||||
Resync map[string]collector.ResyncStatus `json:"resync,omitempty"`
|
||||
// arcrun-rag#159:逐個看守資料夾的同步現況(key=資料夾路徑)。
|
||||
// 同上:形狀定義在 collector/progress.go/sync_status.go,這裡原樣接住。
|
||||
// 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。
|
||||
FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"`
|
||||
}
|
||||
|
||||
type skippedDoc struct {
|
||||
@@ -247,7 +251,20 @@ type UIFolder struct {
|
||||
// arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。
|
||||
// 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到,
|
||||
// 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。
|
||||
ResyncNote string `json:"resyncNote,omitempty"` // 一句人話(沒事=空字串,前端不畫)
|
||||
//
|
||||
// ⚠️ 2026-08-28(arcrun-rag#159)**這一行不再畫在畫面上**——leo:「補送中是什麼
|
||||
// 意思?不要發明奇怪狀態」。同步狀態改由下面 Sync/SyncTip 那一格的圖示表達,
|
||||
// 而它算的是真的份數(見 folder_badge.go)。這個欄位留著給診斷檔與 status.json,
|
||||
// #140 的「不能靜悄悄地重送」由圖示接手:補送中的檔在 manifest 裡就是還沒送成功,
|
||||
// 圖示本來就不會打勾。
|
||||
ResyncNote string `json:"resyncNote,omitempty"`
|
||||
|
||||
// arcrun-rag#159:這一列的同步狀態圖示。Sync 是機器代碼(ok/working/trouble/
|
||||
// unknown),SyncTip 是滑過去看得到的一句短話。判準與文案都在 folder_badge.go
|
||||
// ——**寫在 Go 這一側**,因為「打勾是不是真的」要能被測試守著,
|
||||
// 而前端只負責把代碼換成一個圖示。
|
||||
Sync string `json:"sync"`
|
||||
SyncTip string `json:"syncTip"`
|
||||
}
|
||||
type UIAccount struct {
|
||||
Name string `json:"name"`
|
||||
@@ -257,7 +274,11 @@ type UIAccount struct {
|
||||
// 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新,
|
||||
// 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套
|
||||
// (collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。
|
||||
CloudVerKnown bool `json:"cloudVerKnown"` // false=查不到(連不上/還沒查過),前端要老實說「查不到」
|
||||
CloudVerKnown bool `json:"cloudVerKnown"` // false=**從來沒查到過**這台的版本(不是「這一輪連不上」,見 direct.go #159)
|
||||
// arcrun-rag#159:這一輪 /health 通不通。與 CloudVerKnown 是兩件事——
|
||||
// 版本是事實(查到過就一直知道),可達性是當下狀態(會抽風)。
|
||||
// 前端拿它把版本號調淡並在 tooltip 說明,**不拿它把版本抹掉**。
|
||||
CloudVerFresh bool `json:"cloudVerFresh"`
|
||||
CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新
|
||||
CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值)
|
||||
CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版
|
||||
@@ -518,6 +539,10 @@ func (a *App) GetState() UIState {
|
||||
if rs, ok := sync.Resync[f]; ok {
|
||||
uf.ResyncNote = rs.Note
|
||||
}
|
||||
// #159:這一列打不打勾。key 與 collector 寫入時同一把(資料夾絕對路徑)。
|
||||
// 查不到=collector 還沒回報過這個資料夾 ⇒ folderBadge 回 unknown,不猜。
|
||||
fp, known := sync.FolderProgress[f]
|
||||
uf.Sync, uf.SyncTip = folderBadge(fp, known)
|
||||
ui.Folders = append(ui.Folders, uf)
|
||||
}
|
||||
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
|
||||
@@ -533,6 +558,7 @@ func (a *App) GetState() UIState {
|
||||
// t215:per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost),
|
||||
// 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。
|
||||
if accSt, ok := sync.AccountDetails[ui.Host]; ok {
|
||||
ui.CloudVerFresh = accSt.CloudCheckOK
|
||||
ui.CloudVerKnown = accSt.CloudUpdateKnown
|
||||
ui.CloudVerStale = accSt.CloudUpdateStale
|
||||
ui.CloudVerMine = accSt.CloudVersion
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// folder_badge.go — 資料夾那一列的**同步狀態圖示**(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// leo 2026-08-28 原話:
|
||||
//
|
||||
// 「補送中是什麼意思?**不要發明奇怪狀態**⋯⋯他要知道的是『**我的資料夾是否同步了**』,
|
||||
// **有同步打勾就好**」
|
||||
// 「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」
|
||||
//
|
||||
// ── 這支檔存在的理由 ─────────────────────────────────────────────────────
|
||||
// 在這之前,那一列的標籤是這樣算的(frontend/src/main.js:545):
|
||||
//
|
||||
// <span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
|
||||
//
|
||||
// 也就是說它量的**不是同步狀態**,是「#140 那句補送說明是不是空字串」。
|
||||
// 於是 2026-08-28 leo 的畫面上七列有六列寫「補送中」——連 `youlinhsieh-test1`
|
||||
// (實測 `pending=0`、`repaired=2`,補送早就做完了)也照樣標補送中。
|
||||
// ⇒ **標籤跟事實脫鉤**,而不是措辭不好聽。
|
||||
//
|
||||
// 現在改成從 `collector.SyncProgress` 算——那是 `(*Manifest).Progress()` 的原件,
|
||||
// 與首頁大數字、診斷檔用的是同一個函式。**畫面上打的勾,跟首頁的數字同源。**
|
||||
//
|
||||
// ── 三種狀態,沒有第四種 ─────────────────────────────────────────────────
|
||||
//
|
||||
// ✅ ok Done == Total 每一份我認得的檔都送進知識庫了,而且送上去之後沒再改過
|
||||
// 🔄 working 還沒送完,但沒有出錯的跡象
|
||||
// ⚠️ trouble 已經放棄自動重試(Stuck),或**一份都還沒成功而且每一份都失敗過**
|
||||
//
|
||||
// 🔴 第三種的第二個條件是 leo 那句「Geek6688 很久沒碰了也顯示補送中?」逼出來的。
|
||||
// 實查他機器上的 manifest(2026-08-28):`geek6688-test1` 的 14 份**每一份的
|
||||
// fail_count 都大於 0**(7 與 3),錯誤是雲端回 `HTTP 500 Node post_block failed:
|
||||
// Too many subrequests`,`done=0`。
|
||||
// ⇒ 這不是「排隊中」,是「一直在撞牆」。標成同步中=叫他等一件正在壞掉的事。
|
||||
// ⇒ 但**單看「有檔案在重試」不夠**:`pms` 有 110 份待處理、其中只有 4 份在重試、
|
||||
// 已經送成功 23 份——那是健康的積壓,標警告就變成新的噪音(實測 9 個資料夾裡
|
||||
// 會有 7 個亮警告,等於回到 leo 抱怨的「滿畫面都是狀態」)。
|
||||
// **所以第二個條件要求 `Done == 0`:一份都沒成功、而且全都在失敗,才叫撞牆。**
|
||||
//
|
||||
// 🔴 剛加進來、還沒開始送的資料夾(Failing == 0)落在 working,不會誤報警告。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// 資料夾狀態的機器代碼。前端只認這三個字串,圖示與顏色由 CSS 決定
|
||||
// ——狀態名不進畫面,畫面上只有一個圖示(GUI 的目的是讓人少讀字)。
|
||||
const (
|
||||
folderSyncOK = "ok"
|
||||
folderSyncWorking = "working"
|
||||
folderSyncTrouble = "trouble"
|
||||
// folderSyncUnknown=collector 還沒回報過這個資料夾(剛加進來、第一輪還沒跑完)。
|
||||
// **不能落到 ok**——那會是「還沒查就打勾」,正是這張票的紅線。
|
||||
folderSyncUnknown = "unknown"
|
||||
)
|
||||
|
||||
// folderBadge 把一個資料夾的同步現況翻成「一個狀態代碼+一句短提示」。
|
||||
//
|
||||
// known=status.json 裡有沒有這個資料夾的紀錄。沒有就是 unknown,不猜。
|
||||
//
|
||||
// 提示句刻意短:它住在 tooltip 裡,而 leo 的紅線是「**不要把長句子搬進 tooltip
|
||||
// 裡繼續長**」。要看細節的人有診斷檔(疑難排解那張卡),不是靠這一行。
|
||||
func folderBadge(p collector.SyncProgress, known bool) (state, tip string) {
|
||||
if !known {
|
||||
return folderSyncUnknown, "還在確認這個資料夾"
|
||||
}
|
||||
switch {
|
||||
case p.Total == 0:
|
||||
// 資料夾是空的、或裡面沒有我讀得了的檔。**不是「同步好了」**,
|
||||
// 所以不打勾——打勾要對應「東西真的在知識庫裡」。
|
||||
return folderSyncUnknown, "還沒有可整理的檔案"
|
||||
case p.Done == p.Total:
|
||||
return folderSyncOK, fmt.Sprintf("已同步 · %d 份", p.Done)
|
||||
case p.Stuck > 0:
|
||||
return folderSyncTrouble, fmt.Sprintf("%d 份一直送不上去", p.Stuck)
|
||||
case p.Done == 0 && p.Failing > 0:
|
||||
return folderSyncTrouble, fmt.Sprintf("%d 份都還沒送成功", p.Failing)
|
||||
default:
|
||||
return folderSyncWorking, fmt.Sprintf("同步中 · 還有 %d 份", p.Total-p.Done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// TestDumpBadgeFixture 拿**這台機器上真的 status.json**跑一次 folderBadge,
|
||||
// 把結果印出來/落成畫面驗收要用的 fixture(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// 🔴 為什麼不用手寫 fixture:驗收要貼的是「改後的實際畫面」,而畫面上的圖示
|
||||
// 必須是**真的那個函式**算出來的。手抄一份 fixture 就是第二套實作,
|
||||
// 貼出來的截圖也就不再是證據。
|
||||
//
|
||||
// 預設跳過(要真機資料才有意義):`DUMP_BADGE_FIXTURE=1 go test -run TestDumpBadgeFixture`
|
||||
func TestDumpBadgeFixture(t *testing.T) {
|
||||
if os.Getenv("DUMP_BADGE_FIXTURE") == "" {
|
||||
t.Skip("設 DUMP_BADGE_FIXTURE=1 才跑(要讀這台機器真的 status.json)")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
dir := filepath.Join(home, ".arcrun-rag")
|
||||
|
||||
var st struct {
|
||||
FolderProgress map[string]collector.SyncProgress `json:"folder_progress"`
|
||||
Resync map[string]collector.ResyncStatus `json:"resync"`
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "status.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// status.json 還沒有 folder_progress(那台跑的是舊版)⇒ 從 manifest 現算,
|
||||
// 用的仍是 (*Manifest).Progress() 本尊,不是另一套數法。
|
||||
if len(st.FolderProgress) == 0 {
|
||||
st.FolderProgress = map[string]collector.SyncProgress{}
|
||||
ms, _ := filepath.Glob(filepath.Join(dir, "manifest-official-*.json"))
|
||||
for _, mp := range ms {
|
||||
m, err := collector.LoadManifest(mp, "")
|
||||
if err != nil || m == nil || m.Root == "" {
|
||||
continue
|
||||
}
|
||||
st.FolderProgress[m.Root] = m.Progress()
|
||||
}
|
||||
}
|
||||
|
||||
roots := make([]string, 0, len(st.FolderProgress))
|
||||
for r := range st.FolderProgress {
|
||||
roots = append(roots, r)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
out := []map[string]any{}
|
||||
for _, r := range roots {
|
||||
p := st.FolderProgress[r]
|
||||
state, tip := folderBadge(p, true)
|
||||
fmt.Printf("%-56s total=%-5d done=%-5d pending=%-5d stuck=%-3d failing=%-4d → %-8s %s\n",
|
||||
filepath.Base(r), p.Total, p.Done, p.Pending, p.Stuck, p.Failing, state, tip)
|
||||
out = append(out, map[string]any{
|
||||
"path": r, "sync": state, "syncTip": tip,
|
||||
"resyncNote": st.Resync[r].Note,
|
||||
})
|
||||
}
|
||||
if dest := os.Getenv("BADGE_FIXTURE_OUT"); dest != "" {
|
||||
j, _ := json.MarshalIndent(out, "", " ")
|
||||
if err := os.WriteFile(dest, j, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("fixture 寫到 %s", dest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// TestFolderBadge 守住「打勾是真的」這條紅線(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// leo 的紅線原文:「打勾要是**真的**:說得出『打勾』的判準是什麼,
|
||||
// **不能是『沒有錯誤就打勾』**」。
|
||||
// ⇒ 下面每一格都在問同一件事:**這個狀態對應得上 manifest 裡的哪個事實?**
|
||||
func TestFolderBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
p collector.SyncProgress
|
||||
known bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// 打勾的唯一判準:認得的檔**每一份**都送上去了,而且送上去之後沒再改過
|
||||
// (Done 的定義見 progress.go:IngestedHash != "" && == ContentHash)。
|
||||
name: "全部送完才打勾",
|
||||
p: collector.SyncProgress{Total: 19, Done: 19},
|
||||
known: true,
|
||||
want: folderSyncOK,
|
||||
},
|
||||
{
|
||||
name: "差一份就不准打勾",
|
||||
p: collector.SyncProgress{Total: 19, Done: 18, Pending: 1},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 🔴 「沒有錯誤就打勾」的反例:沒有任何失敗,但也還沒送完。
|
||||
name: "零錯誤但沒送完=同步中,不是打勾",
|
||||
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// leo 2026-08-28 桌面實測值:geek6688-test1(14 份全在重試、一份都沒成功,
|
||||
// 雲端回 HTTP 500 Too many subrequests)。這是「不再標成進行中」的那一格。
|
||||
name: "一份都沒成功而且全都在重試=警告",
|
||||
p: collector.SyncProgress{Total: 14, Done: 0, Pending: 14, Failing: 14},
|
||||
known: true,
|
||||
want: folderSyncTrouble,
|
||||
},
|
||||
{
|
||||
// 對照組:健康的積壓不准亮警告,不然畫面又變回「滿滿都是狀態」。
|
||||
// 實測值取自 pms(110 待處理裡只有 4 份在重試,已經成功 23 份)。
|
||||
name: "有成功過的積壓=同步中,不亮警告",
|
||||
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110, Failing: 4},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 剛加進來、還沒開始送:一份都沒成功,但也還沒失敗過 ⇒ 不准嚇人。
|
||||
name: "剛加進來還沒送=同步中",
|
||||
p: collector.SyncProgress{Total: 20, Done: 0, Pending: 20},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 已放棄自動重試=**不會自己好**,就算大多數都送成功了也要講。
|
||||
name: "有放棄重試的就亮警告",
|
||||
p: collector.SyncProgress{Total: 4184, Done: 817, Pending: 3342, Stuck: 25, Failing: 95},
|
||||
known: true,
|
||||
want: folderSyncTrouble,
|
||||
},
|
||||
{
|
||||
// 空資料夾不准打勾——打勾要對應「東西真的在知識庫裡」。
|
||||
name: "沒有可整理的檔案不打勾",
|
||||
p: collector.SyncProgress{},
|
||||
known: true,
|
||||
want: folderSyncUnknown,
|
||||
},
|
||||
{
|
||||
// 🔴 collector 還沒回報 ⇒ 零值。舊寫法會讓它看起來「什麼問題都沒有」,
|
||||
// 在這裡它必須是 unknown,不能落到 ok。
|
||||
name: "還沒回報過不准打勾",
|
||||
p: collector.SyncProgress{},
|
||||
known: false,
|
||||
want: folderSyncUnknown,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, tip := folderBadge(c.p, c.known)
|
||||
if got != c.want {
|
||||
t.Fatalf("folderBadge(%+v, known=%v) = %q,預期 %q", c.p, c.known, got, c.want)
|
||||
}
|
||||
if strings.TrimSpace(tip) == "" {
|
||||
t.Fatalf("每個狀態都要講得出自己是什麼(leo 驗收條件 3),但 tip 是空的")
|
||||
}
|
||||
// 紅線:提示句是**產品文案**,不准出現狀態碼/技術詞;也不准長成一段說明
|
||||
// (leo:「不要把長句子搬進 tooltip 裡繼續長」)。
|
||||
for _, bad := range []string{"HTTP", "500", "error", "token", "manifest", "pending", "stuck"} {
|
||||
if strings.Contains(strings.ToLower(tip), strings.ToLower(bad)) {
|
||||
t.Fatalf("提示句出現技術詞 %q:%q", bad, tip)
|
||||
}
|
||||
}
|
||||
if n := len([]rune(tip)); n > 20 {
|
||||
t.Fatalf("提示句 %d 字,太長了(上限 20):%q", n, tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -166,25 +166,39 @@ function cardKbVersions(s) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯,
|
||||
// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。
|
||||
// kbVersionLine:單一知識庫的版本,首頁卡與各庫頁共用同一份(不讓兩處各寫各的)。
|
||||
//
|
||||
// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」):
|
||||
// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法)
|
||||
// ② Known 且已最新 → 淡字「已是最新版」
|
||||
// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版
|
||||
// 🔴 `inkstone/arcrun-rag#159`(leo 2026-08-28):「**GUI 是國際通用,圖形指示,
|
||||
// 不限語文**,所以任何出現文字都要謹慎,文字表示『我的設計不夠好所以要靠文字來補』」
|
||||
// ⇒ 這一行以前是三句話(「有新版可更新(目前 x → 最新 y)」「已是最新版(x)」
|
||||
// 「目前連不上這個知識庫,查不到版本」)。現在只剩**版本號與圖示**:
|
||||
//
|
||||
// 1.4.60 ✅ 已是最新
|
||||
// 1.4.46 → 1.4.58 ⬆️ 有新版(按鈕,按下去帶 email 開安裝頁)
|
||||
// ○ 從來沒查到過這台的版本
|
||||
//
|
||||
// 說明都收進 title(滑過去才出現),版面上一個字都不佔。
|
||||
//
|
||||
// 🔴 「這一輪連不上」**不再抹掉版本號**(見 collector/direct.go 那段註解):
|
||||
// 版本查到過就是事實,抽風的是網路。連不上時版本號調淡,滑過去講得出來。
|
||||
function kbVersionLine(a) {
|
||||
const dim = a.cloudVerFresh ? '' : ' dim';
|
||||
const tip = a.cloudVerFresh ? '' : '上次查到的版本(現在暫時連不上)';
|
||||
if (!a.cloudVerKnown) {
|
||||
const detail = a.cloudVerMine
|
||||
? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)`
|
||||
: '目前連不上這個知識庫,查不到版本';
|
||||
return `<span class="d">${detail}</span>`;
|
||||
// 分得出「沒查到這台的版本」與「查到了但暫時不知道最新版是多少」——
|
||||
// 前者連版本號都沒有,後者有版本號可以顯示。
|
||||
if (!a.cloudVerMine) {
|
||||
return `<span class="kbv unknown" role="img" title="還沒查到這個知識庫的版本">○</span>`;
|
||||
}
|
||||
return `<span class="kbv${dim}" title="${esc(tip || '暫時查不到最新版本,稍後自動再查')}">${esc(a.cloudVerMine)}</span>`;
|
||||
}
|
||||
if (a.cloudVerStale) {
|
||||
return `<span class="warn">有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)})</span>
|
||||
<button class="ghost" data-updatekb="${esc(a.email || '')}">前往安裝頁更新</button>`;
|
||||
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)} → ${esc(a.cloudVerLatest)}</span>
|
||||
<button class="ico" data-updatekb="${esc(a.email || '')}"
|
||||
title="前往安裝頁更新這個知識庫" aria-label="前往安裝頁更新這個知識庫">⬆️</button>`;
|
||||
}
|
||||
return `<span class="d">已是最新版(${esc(a.cloudVerMine)})</span>`;
|
||||
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}</span>
|
||||
<span class="fstat ok" role="img" title="已是最新版" aria-label="已是最新版">✅</span>`;
|
||||
}
|
||||
|
||||
// installURLFor:與 portal 版本卡同一個做法——落後才需要按,按下去帶 email 讓安裝頁
|
||||
@@ -233,6 +247,18 @@ function cardQuota(q, p) {
|
||||
if (!q) return '';
|
||||
const pending = p && p.pending > 0
|
||||
? `<div class="d" style="margin-top:6px">還有 <b>${p.pending}</b> 份排隊中——會自動接著跑,你不用重丟。</div>` : '';
|
||||
// arcrun-rag#197:雲端資料庫(D1)額度用完是另一種卡——沒有「成就」可講,
|
||||
// 用戶要的是:哪一種額度、上限多少/用到哪、幾點恢復、要不要自己做事。文字全來自後端。
|
||||
if (q.kind === 'd1_read' || q.kind === 'd1_write') {
|
||||
return `
|
||||
<div class="card" data-quota-kind="${esc(q.kind)}">
|
||||
<h3>${esc(q.headline)}</h3>
|
||||
<div class="d" style="margin-top:6px">${esc(q.usage)}。</div>
|
||||
<div class="d" style="margin-top:6px"><b>${esc(q.guarantee)}</b>。</div>
|
||||
${pending}
|
||||
<div class="d" style="margin-top:6px">急著要的話:${esc(q.exit_options)}。</div>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="card">
|
||||
<h3>${esc(q.achievement)} 🎉</h3>
|
||||
@@ -320,7 +346,18 @@ function cardSkipped(k) {
|
||||
// data:path → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
|
||||
// open:path → 這個資料夾的樹展開了沒
|
||||
// nodes:path → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen)
|
||||
const treeState = { data: {}, open: {}, nodes: {} };
|
||||
// why:path → { 節點路徑: 這一列的「為什麼」被點開了沒 }(arcrun-rag#159)
|
||||
const treeState = { data: {}, open: {}, nodes: {}, why: {} };
|
||||
|
||||
// ── 資料夾那一列的狀態圖示(`inkstone/arcrun-rag#159`)────────────────────
|
||||
//
|
||||
// leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
|
||||
// 畫面上都是字壓力很大**」「他要知道的是『我的資料夾是否同步了』,**有同步打勾就好**」
|
||||
//
|
||||
// 🔴 這裡**只做代碼→圖示的對照**,一個判斷都不做。
|
||||
// 「該不該打勾」住在 Go 那一側(cmd/arcrun-app/folder_badge.go,有測試守著)——
|
||||
// 前端自己判斷就會變成第二套判準,遲早跟後端說的不一樣。
|
||||
const SYNC_ICON = { ok: '✅', working: '🔄', trouble: '⚠️', unknown: '○' };
|
||||
|
||||
// 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。
|
||||
function treeBoxId(path) {
|
||||
@@ -390,13 +427,12 @@ function renderFolderTree(path) {
|
||||
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
|
||||
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
|
||||
if (tree === null) {
|
||||
box.innerHTML = `<div class="ftmsg">同步小幫手還沒掃到這個資料夾的結構。
|
||||
第一次同步跑完之後就會出現——檔案多的話要等久一點,按上面的「立刻同步」可以催它。</div>`;
|
||||
box.innerHTML = `<div class="ftmsg">還沒掃到這個資料夾,第一次同步跑完就會出現。</div>`;
|
||||
return;
|
||||
}
|
||||
const nodes = tree.nodes || [];
|
||||
if (!nodes.length) {
|
||||
box.innerHTML = `<div class="ftmsg">這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。</div>`;
|
||||
box.innerHTML = `<div class="ftmsg">這個資料夾目前是空的。</div>`;
|
||||
return;
|
||||
}
|
||||
const r = rollupTree(nodes);
|
||||
@@ -421,25 +457,37 @@ function renderFolderTree(path) {
|
||||
+ `<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 && n.total_files === 0) {
|
||||
// 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。
|
||||
const why = n.skip_reason || '(小幫手沒說明理由)';
|
||||
row += `<span class="skip">整個資料夾未收</span>`
|
||||
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
|
||||
} else if (n.skipped) {
|
||||
// #180:走進去了、數字是真的,只是這一層一個檔都沒收
|
||||
// ⇒ **數字照顯示**(他要知道底下有幾個),理由也照講。
|
||||
const why = n.skip_reason || '(小幫手沒說明理由)';
|
||||
row += `<span class="num">${s.synced} / ${s.total}</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>`;
|
||||
// ── 一列只有:三角形 + 資料夾名 + `X / Y`(`inkstone/arcrun-rag#159`)──
|
||||
//
|
||||
// leo 2026-08-28:「**上下廢話刪除**,**產生 GUI 就是要讓人減少讀字降低負擔,
|
||||
// 你在 GUI 寫這麼多字剛好違背它的原理**」——他的截圖上同一句
|
||||
// 「這是安裝範本時鋪下來的空白樣板…」**在一棵樹裡出現了 5 次**。
|
||||
//
|
||||
// 🔴 **「收起來」不等於「刪掉」**(本票紅線):那些理由是使用者要救回被跳過的
|
||||
// 資料夾時的線索(`inkstone/arcrun-rag#136`)。所以理由沒有被拿掉,
|
||||
// 只是**不再預設佔畫面**——點那個數字就展開這一列自己的理由。
|
||||
// 🔴 也**不塞進 tooltip**(同一條紅線:「不要把長句子搬進 tooltip 裡繼續長」),
|
||||
// tooltip 只有「點一下看原因」這種操作提示。
|
||||
let why = n.skipped
|
||||
? (n.skip_reason || '(小幫手沒說明理由)')
|
||||
: gapWhy(s);
|
||||
// 收檔策略那句話(原本掛在樹的上方,leo 圈掉了)改掛在**根那一列**——
|
||||
// 它講的就是這個監看根,點根的數字就看得到,資訊沒有消失。
|
||||
if (n.parent === '-' && tree.reason) why = why ? `${tree.reason}(${why})` : tree.reason;
|
||||
// 整棵沒走進去(#180 之後:skipped 且 total_files 為 0)⇒ 不准顯示 0/0,
|
||||
// 那會是我們自己編的數字。用「—」表示「這個數字我沒有」。
|
||||
const noCount = n.skipped && n.total_files === 0;
|
||||
const full = !noCount && s.total > 0 && s.synced === s.total;
|
||||
const shown = noCount ? '—' : `${s.synced} / ${s.total}`;
|
||||
row += `<span class="num${full ? ' full' : ''}${why ? ' hasWhy' : ''}"`
|
||||
+ (why
|
||||
? ` data-twhy="${esc(n.path)}" data-twroot="${esc(path)}" role="button" tabindex="0" title="點一下看原因"`
|
||||
: '')
|
||||
+ `>${shown}</span>`;
|
||||
html += row + `</div>`;
|
||||
if (why && (treeState.why[path] || {})[n.path]) {
|
||||
html += `<div class="ftwhy" style="padding-left:${indent + 21}px">${esc(why)}</div>`;
|
||||
}
|
||||
if (open) kids.forEach(emit);
|
||||
}
|
||||
(r.kids['-'] || []).forEach(emit);
|
||||
@@ -448,17 +496,22 @@ function renderFolderTree(path) {
|
||||
nodes.forEach((n) => { seen[n.path] = true; });
|
||||
nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); });
|
||||
|
||||
// 🔴 樹的上方與下方**什麼都不留**(`inkstone/arcrun-rag#159`,leo 在截圖上圈了三處):
|
||||
// ① 上方的「全部展開/全部收合 + 這是一般資料夾或筆記庫…」整條拿掉
|
||||
// ② 下方的「數字是『已同步 / 這個資料夾底下總共』…」整段拿掉
|
||||
// ③ 下方的補送說明拿掉(狀態改由資料夾那一列的圖示表達)
|
||||
// leo 原話:「**上下廢話刪除,只留乾淨 Tree**」。
|
||||
//
|
||||
// 收檔策略那句話(tree.reason)沒有消失——它跟每一列的理由走同一條路:
|
||||
// 點根那一列的數字就看得到(emit() 裡的 hasWhy)。
|
||||
//
|
||||
// **唯一的例外是截斷警告**:它只在樹真的不完整時才出現,而不講就是讓畫面
|
||||
// 宣稱「這是全部」——那是說謊,不是廢話。
|
||||
let foot = '';
|
||||
if (tree.truncated) {
|
||||
foot += `<div class="ftmsg">資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。</div>`;
|
||||
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}`;
|
||||
box.innerHTML = `<div class="ftbody">${html}</div>${foot}`;
|
||||
wireTree();
|
||||
}
|
||||
|
||||
@@ -467,8 +520,13 @@ function renderFolderTree(path) {
|
||||
async function toggleFolderTree(path) {
|
||||
treeState.open[path] = !treeState.open[path];
|
||||
const box = $(treeBoxId(path));
|
||||
// #159:那顆按鈕現在只有一個 CSS 畫的三角形(沒有文字),轉向由 class 決定。
|
||||
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
|
||||
if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構';
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', String(!!treeState.open[path]));
|
||||
const row = btn.closest('.folder');
|
||||
if (row) row.classList.toggle('open', !!treeState.open[path]);
|
||||
}
|
||||
if (!box) return;
|
||||
box.style.display = treeState.open[path] ? '' : 'none';
|
||||
if (!treeState.open[path]) return;
|
||||
@@ -487,16 +545,6 @@ async function toggleFolderTree(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 = () => {
|
||||
@@ -510,11 +558,19 @@ function wireTree() {
|
||||
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);
|
||||
// #159:點數字=展開這一列自己的「為什麼」。理由沒有被刪掉,只是不再預設佔畫面。
|
||||
// stopPropagation:這個數字常常長在「整列可點=展開子資料夾」的列上,
|
||||
// 不擋住的話點原因會順便把子資料夾收掉。
|
||||
document.querySelectorAll('[data-twhy]').forEach((el) => {
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
const root = el.dataset.twroot, np = el.dataset.twhy;
|
||||
const st = treeState.why[root] || (treeState.why[root] = {});
|
||||
st[np] = !st[np];
|
||||
renderFolderTree(root);
|
||||
};
|
||||
el.onclick = toggle;
|
||||
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,14 +596,17 @@ function pageLib(s, idx) {
|
||||
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`}</span>
|
||||
</div>
|
||||
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
|
||||
<div class="folder">
|
||||
<div class="folder${treeState.open[f.path] ? ' open' : ''}">
|
||||
<button class="tw" data-tree="${esc(f.path)}" aria-expanded="${!!treeState.open[f.path]}"
|
||||
title="展開這個資料夾" aria-label="展開或收合這個資料夾的內容"><i></i></button>
|
||||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</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>
|
||||
<span class="fstat ${esc(f.sync || 'unknown')}" role="img"
|
||||
title="${esc(f.syncTip || '')}" aria-label="${esc(f.syncTip || '')}"
|
||||
>${SYNC_ICON[f.sync] || SYNC_ICON.unknown}</span>
|
||||
<button class="ico" data-rm="${esc(f.path)}" data-acc="${f.accIdx}"
|
||||
title="移除這個資料夾" aria-label="移除這個資料夾並從知識庫收回">🗑</button>
|
||||
</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="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>`).join('')
|
||||
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
|
||||
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,41 @@ button.ghost:hover { color: var(--err); background: transparent; }
|
||||
.folder .tag.retiring { color: rgba(var(--ink-rgb), .5); }
|
||||
.folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; }
|
||||
|
||||
/* ══ arcrun-rag#159:那一列只剩「三角形 + 路徑 + 兩個圖示」══════════════
|
||||
leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
|
||||
畫面上都是字壓力很大**」「**展開三角形按鈕放資料夾上**」
|
||||
⇒ 三角形移到列首(跟樹裡面的展開鈕同一個造型,使用者只要學一次)。 */
|
||||
.folder > .tw {
|
||||
flex: none; width: 22px; height: 22px; padding: 0; border: none; background: none;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.folder > .tw:hover { background: none; }
|
||||
/* 三角形用 CSS 畫,不用 ▸▾ 字元——理由同樹裡那顆(見 .ftrow .tw i):
|
||||
那兩個字在 Windows 的中文字型裡是全形,大小與位置每台機器都不一樣。 */
|
||||
.folder > .tw i {
|
||||
display: block; width: 0; height: 0;
|
||||
border-left: 6px solid rgba(var(--ink-rgb), .5);
|
||||
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
|
||||
transition: transform .12s;
|
||||
}
|
||||
.folder.open > .tw i { transform: rotate(90deg); }
|
||||
.folder > .tw:hover i { border-left-color: var(--amber); }
|
||||
/* 狀態圖示。**一個字都沒有**——說明住在 title/aria-label 裡,滑過去才出現。 */
|
||||
.folder .fstat { flex: none; font-size: 14px; line-height: 1; cursor: default; }
|
||||
/* 🔴 視覺層級要對得上意思:**只有需要你出手的那一格才准大聲**。
|
||||
「同步中」是常態、不需要任何人做什麼 ⇒ 壓到跟灰階一樣安靜;
|
||||
⚠️ 維持原色,因為它是唯一一個「你可能要看一下」的狀態。
|
||||
(不壓的話 🔄 的藍色會比 ⚠️ 還跳,畫面就在對使用者喊錯的東西。) */
|
||||
.folder .fstat.working { filter: grayscale(.85); opacity: .5; }
|
||||
/* 還沒回報過的那一格刻意做得很淡:它不是「沒問題」,是「還不知道」。 */
|
||||
.folder .fstat.unknown { color: rgba(var(--ink-rgb), .3); font-size: 12px; }
|
||||
/* 圖示按鈕(移除):跟三角形同一個量級,hover 才變紅——不預設嚷嚷。 */
|
||||
.folder .ico {
|
||||
flex: none; padding: 4px 6px; border: none; background: none;
|
||||
font-size: 14px; line-height: 1; filter: grayscale(1); opacity: .7;
|
||||
}
|
||||
.folder .ico:hover { background: none; filter: none; opacity: 1; }
|
||||
|
||||
.big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; }
|
||||
.kv { display: flex; gap: 26px; margin-top: 4px; }
|
||||
.kv .k { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }
|
||||
@@ -299,6 +334,14 @@ input[type=text], input[type=password] {
|
||||
.kbrow .nm { font-size: 14px; font-weight: 600; flex: none; }
|
||||
.kbrow .right { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.kbver { margin-bottom: 14px; font-size: 13.5px; display: flex; align-items: center; gap: 10px; }
|
||||
/* arcrun-rag#159:版本那一格只有**數字與圖示**,說明收進 title。
|
||||
等寬字讓 `1.4.46 → 1.4.58` 這種對照掃視時對得齊。 */
|
||||
.kbv { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: rgba(var(--ink-rgb), .7); }
|
||||
/* 這一輪連不上 ⇒ 版本號調淡表示「這是上次查到的」。**不是把它抹掉**——
|
||||
版本查到過就是事實,抽風的是網路(見 collector/direct.go #159 那段)。 */
|
||||
.kbv.dim { opacity: .45; }
|
||||
/* 從來沒查到過:一個很淡的圈,跟資料夾那一列的 unknown 同一個語彙。 */
|
||||
.kbv.unknown { font-family: inherit; color: rgba(var(--ink-rgb), .3); }
|
||||
.warn { color: var(--err); font-size: 13px; }
|
||||
#side .nav .dot.warn {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex: none;
|
||||
@@ -432,12 +475,10 @@ input[type=text], input[type=password] {
|
||||
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; }
|
||||
/* 🔴 arcrun-rag#159:`.fthead`(全部展開/全部收合+收檔策略那句話)**整條移除**。
|
||||
leo 2026-08-28 在截圖上圈掉它:「上下廢話刪除,只留乾淨 Tree」
|
||||
「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」。
|
||||
收檔策略那句話沒有被刪掉——它改掛在根那一列的數字上,點一下就看得到。 */
|
||||
/* 樹本體:檔案總管那種「緊湊但點得到」的密度。
|
||||
overflow-x 讓很深的樹自己捲,不去撐破右側版面。 */
|
||||
.ftbody { overflow-x: auto; padding: 1px 0; }
|
||||
@@ -474,6 +515,19 @@ input[type=text], input[type=password] {
|
||||
}
|
||||
/* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */
|
||||
.ftrow .num.full { color: var(--ink); }
|
||||
/* #159:這一列有「為什麼」可以看時,數字自己就是那顆按鈕(畫面上不多一個字、
|
||||
也不多一顆按鈕)。虛線底線是唯一的提示——**它不是裝飾,是可點的告示**。 */
|
||||
.ftrow .num.hasWhy {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dotted rgba(var(--ink-rgb), .35);
|
||||
}
|
||||
.ftrow .num.hasWhy:hover { color: var(--ink); border-bottom-color: var(--amber); }
|
||||
.ftrow .num.hasWhy:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
|
||||
/* 點開之後那一行理由。預設不存在——這就是「收起來不等於刪掉」的落點。 */
|
||||
.ftwhy {
|
||||
font-size: 12px; line-height: 1.6; padding: 1px 4px 4px;
|
||||
color: rgba(var(--ink-rgb), .55); white-space: normal; max-width: 46em;
|
||||
}
|
||||
/* 🔴 選擇器**不要**綁在 .ftrow 底下:頁首那句「為什麼只收這些」也用同一個 class,
|
||||
綁死就只有樹裡面吃得到樣式、頁首那句會掉回 16px 的內文級——實撞過一次,
|
||||
畫面上那句話比整棵樹都大聲,喧賓奪主。 */
|
||||
@@ -484,12 +538,6 @@ input[type=text], input[type=password] {
|
||||
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);
|
||||
|
||||
@@ -521,10 +521,21 @@ func countsAsDocument(r DirectResult) bool {
|
||||
//(收卡那條路,可能是使用者剛存的新檔,也可能是在補修舊筆記的出處)——
|
||||
// 所以 step 由呼叫端給,不從網址反推,反推出來的名字會說謊。
|
||||
func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, string, error) {
|
||||
return c.postJSONAs(step, url, body, false)
|
||||
}
|
||||
|
||||
// postJSONAs=postJSON,多帶一個 retry:這份內容先前就失敗過。
|
||||
// 它只影響「這一發失敗算不算這條路壞了」(`inkstone/arcrun-rag#121`,見 routebackoff.go 檔頭)。
|
||||
func (c *DirectConfig) postJSONAs(step callStep, url string, body any, retry bool) (int, string, error) {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
// 🔴 `inkstone/arcrun-rag#121`:這條路正在退避 ⇒ 連打都不打(見 routebackoff.go)。
|
||||
// 放在這裡而不是各呼叫端:所有寫雲端的路都經過這一個函式,「忘了接」不該存在。
|
||||
if note := c.routeNote(url); note != "" {
|
||||
return 0, "", &routeBackoffError{note: note}
|
||||
}
|
||||
gate := c.openGate(step)
|
||||
defer gate.release() // context 要活到下面讀完回應為止,所以是 defer 不是就地釋放
|
||||
// 這一輪已經判定這個帳號沒有回應 ⇒ 連打都不打,立刻回頭讓其他資料夾繼續。
|
||||
@@ -539,10 +550,12 @@ func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, strin
|
||||
req.Header.Set("X-Arcrun-API-Key", c.APIKey)
|
||||
resp, err := directHTTP.Do(req)
|
||||
if err != nil {
|
||||
cloudRoutes.record(url, directNow(), 0, err, retry) // #121:連不上/逾時也算這條路失敗
|
||||
return 0, "", gate.record(err)
|
||||
}
|
||||
gate.ok() // #153:回來了就把「連續逾時」的計數歸零——否則「連續」兩個字是假的
|
||||
defer resp.Body.Close()
|
||||
cloudRoutes.record(url, directNow(), resp.StatusCode, nil, retry) // #121:5xx/429 記一次失敗,2xx 歸零
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
|
||||
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
|
||||
@@ -552,6 +565,11 @@ func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, strin
|
||||
snippet = snippet[:1024]
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// arcrun-rag#197:雲端把 D1 額度用完的原文傳回來了(新舊雲端不一定都會)⇒ 記下,
|
||||
// 下一發起整個帳號停打,不必等下一分鐘的 /health。
|
||||
if k := d1QuotaKind(string(full)); k != "" {
|
||||
noteD1Quota(c.CypherURL, k, directNow())
|
||||
}
|
||||
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
|
||||
@@ -621,6 +639,11 @@ func (c *DirectConfig) makeAccountSubConfig(acc AccountConfig) *DirectConfig {
|
||||
return &sub
|
||||
}
|
||||
|
||||
// directNow=一輪的「現在」。正式執行就是 time.Now;
|
||||
// 測試要模擬「雲端持續失敗 N 分鐘」時換成假時鐘(`inkstone/arcrun-rag#121` 的量測),
|
||||
// 否則所有退避窗口都得真的等上幾分鐘才看得到效果。
|
||||
var directNow = time.Now
|
||||
|
||||
// RunDirectOnce 對每個帳號的每個監看根掃一輪並彙總結果(t104 多帳號同時看守)。
|
||||
// 單帳號行為與舊制完全相同(含 manifest 路徑)。回傳彙總結果與退出碼建議(任一根失敗=1)。
|
||||
// 額外:
|
||||
@@ -631,7 +654,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
results := []DirectResult{}
|
||||
exit := 0
|
||||
var lastPayload *TriggerPayload
|
||||
now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
|
||||
now := directNow() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
|
||||
|
||||
// 🔴 `inkstone/arcrun-rag#153`:這一輪的「等待閘」。每輪換一份新的——斷路器只管
|
||||
// 這一輪,下一輪一律從零開始重新試(同步是 level-triggered 的,沒有什麼要記住)。
|
||||
@@ -742,6 +765,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
folderTrees := map[string]FolderTree{}
|
||||
var knownRoots []string
|
||||
|
||||
// `inkstone/arcrun-rag#159`:每個看守資料夾這一輪的同步現況(key=資料夾路徑)。
|
||||
// 值是 (*Manifest).Progress() 的原件——與 totalProgress 同一個來源,只是沒有累加。
|
||||
folderProgress := map[string]SyncProgress{}
|
||||
|
||||
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
|
||||
var totalProgress SyncProgress
|
||||
var stuckReasons []string
|
||||
@@ -767,9 +794,29 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
accHost := instanceHostOf(acc.CypherURL)
|
||||
|
||||
// t103:per-account 雲端版本偵測
|
||||
cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL)
|
||||
cloudVer, cloudOK := cloudVersionThrottled(accCfg.CypherURL, cfg.ForceSync) // #121:一分鐘問一次,見 cloudcheck.go
|
||||
// 🔴 `inkstone/arcrun-rag#159`:**一次探測失敗 ≠ 這台知識庫連不上。**
|
||||
//
|
||||
// leo 2026-08-28 的畫面上,`youlin.hsieh.dev` 那行紅字寫「目前連不上這個
|
||||
// 知識庫,查不到版本」——而同一台機器上 curl 打同一支 /health 是 HTTP 200
|
||||
// 亞秒回應。實查當下 status.json:**三個帳號全部** `cloud_check_ok=false`、
|
||||
// 錯誤都是 `dial tcp: ... no such host`,而剛開的 Go 行程(cgo 與純 Go
|
||||
// 兩種 resolver 都試過)解得出來、GET 也 200。
|
||||
// ⇒ 那是這台機器當下的 DNS 抽風,不是知識庫掛了。
|
||||
//
|
||||
// 舊寫法把「這一輪沒查到」直接呈現成「查不到版本」,於是**一次抽風就抹掉
|
||||
// 我們早就知道的事實**。改成:查不到就沿用上一輪查到的版本
|
||||
//(同 CarryForwardActivity 的理由),只有**從來沒查到過**才是真的不知道。
|
||||
// CloudCheckOK 仍照實記這一輪的可達性——那是另一件事,不混在一起。
|
||||
if strings.TrimSpace(cloudVer) == "" {
|
||||
if prevAcc, ok := prevStatus.AccountDetails[accHost]; ok {
|
||||
cloudVer = prevAcc.CloudVersion
|
||||
}
|
||||
}
|
||||
// t215:這個帳號要不要更新——與 portal 版本卡同一套判準(見檔頭)。
|
||||
cloudUpd := EvalCloudUpdate(cloudVer, cloudOK, latestRelease, latestOK)
|
||||
// 第二個參數傳「我們知不知道它的版本」而不是「這一輪連不連得上」:
|
||||
// 版本是**事實**,可達性是**當下狀態**,兩者不是同一個問題。
|
||||
cloudUpd := EvalCloudUpdate(cloudVer, strings.TrimSpace(cloudVer) != "", latestRelease, latestOK)
|
||||
accSt := AccountSyncStatus{
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
CloudVersion: cloudVer,
|
||||
@@ -861,6 +908,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...)
|
||||
// #104:這一根用了什麼策略、少收了什麼 —— 以前到這裡就被丟掉了
|
||||
// (只有 CLI 的 stderr 講得出來,App 走的這條路一個字都不說)。
|
||||
// #159:這一根的同步現況。與 folderPlans 同一個「掃成了才記」的閘——
|
||||
// 掃壞的那輪 rootProgress 是零值,記下去畫面會把它讀成「0 份、已同步」
|
||||
// =拿我們自己編的答案打勾。沒記的根由下面的沿用機制補上一輪的。
|
||||
folderProgress[root] = rp.Progress
|
||||
folderPlans[root] = FolderPlanStatus{
|
||||
Mode: string(p.Plan.Mode),
|
||||
Reason: p.Plan.Reason,
|
||||
@@ -912,6 +963,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 冷卻已過且本輪沒有新命中 ⇒ QuotaCooldownUntil/QuotaMessage 維持零值,
|
||||
// 自然清除舊訊息(accSt 每輪重建,不會殘留上一輪的冷卻通知)。
|
||||
|
||||
// arcrun-rag#197:雲端資料庫額度用完**蓋過** AI 額度那張卡——資料庫讀不到時,
|
||||
// 萃取、收卡、下架全部做不成,講 AI 額度只會讓用戶以為換個模型就好。
|
||||
// 不寫 QuotaCooldownUntil:那一格是 Workers AI 冷卻的跨輪紀錄,混用會讓 D1 恢復後
|
||||
// 萃取還被多擋一段;D1 的狀態每分鐘由 /health 重新確認(cloudquota.go)。
|
||||
if st, ok := activeD1Quota(accCfg.CypherURL, now); ok {
|
||||
notice := buildD1QuotaNotice(st.kind, now, st.until)
|
||||
accSt.QuotaMessage = ¬ice
|
||||
extractorOK = false
|
||||
extractorError = notice.Headline + "。" + notice.Guarantee
|
||||
}
|
||||
|
||||
accountDetails[accHost] = accSt
|
||||
}
|
||||
|
||||
@@ -952,6 +1014,21 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if len(folderPlans) > 0 {
|
||||
st.FolderPlans = folderPlans
|
||||
}
|
||||
// #159:逐資料夾的同步現況。這一輪沒掃成的根**沿用上一輪**
|
||||
// (同 MergeFolderTreeStore ② 的理由:畫面不該因為一輪掃壞就說「沒有狀態」);
|
||||
// 已經不在看守清單上的根自然消失——folderProgress 的 key 只可能來自本輪的
|
||||
// knownRoots,移除掉的資料夾不會被沿用回來。
|
||||
for _, root := range knownRoots {
|
||||
if _, ok := folderProgress[root]; ok {
|
||||
continue
|
||||
}
|
||||
if prev, ok := prevStatus.FolderProgress[root]; ok {
|
||||
folderProgress[root] = prev
|
||||
}
|
||||
}
|
||||
if len(folderProgress) > 0 {
|
||||
st.FolderProgress = folderProgress
|
||||
}
|
||||
st.SkippedDocCount = len(skippedSeen)
|
||||
for _, sf := range skippedSeen {
|
||||
st.SkippedDocs = append(st.SkippedDocs, sf)
|
||||
@@ -1123,19 +1200,40 @@ func saveDirectConfig(configPath string, cfg *DirectConfig) error {
|
||||
// 相同 ⇒ 撤除其中一個會連坐另一個。library 是逐根導出的(libraryFor),把它一起送上去,
|
||||
// 雲端才有辦法只殺對的那一份。這與 ingest 送的 library 是**同一個函式**算出來的,
|
||||
// 守 2026-07-24 那條教訓:成對操作(上架/下架)要用同一把鍵。
|
||||
//
|
||||
// maxPerRun(arcrun-rag#104 comment 6309):單輪最多撤幾筆,0=不限。
|
||||
// 以前這份清單只裝改名留下的舊頁(一輪一兩筆),撤完為止沒問題;現在資料夾卡
|
||||
// 消失也排進來(leo21c 的 KB 光 `logseq/bak/` 底下就是上千個資料夾),
|
||||
// 一輪全撤會撞單輪等待上限(stallguard)與雲端 subrequest 額度。
|
||||
// 排隊的照 removed 事件同一個單輪上限走,沒撤到的下一輪接著撤——清單本來就是持久的。
|
||||
func drainPendingTakedowns(
|
||||
cfg *DirectConfig, m *Manifest, absRoot, resultType, failPrefix string,
|
||||
pace func(), dryRun bool, saveManifest func(),
|
||||
pace func(), dryRun bool, saveManifest func(), maxPerRun int,
|
||||
) ([]DirectResult, int) {
|
||||
var results []DirectResult
|
||||
exit := 0
|
||||
if len(m.PendingTakedowns) == 0 {
|
||||
return results, exit
|
||||
}
|
||||
// 固定順序:map 走訪是隨機的,套上限之後若不排序,同一批會隨機輪流被跳過。
|
||||
pending := make([]string, 0, len(m.PendingTakedowns))
|
||||
for oldPath := range m.PendingTakedowns {
|
||||
pending = append(pending, oldPath)
|
||||
}
|
||||
sort.Strings(pending)
|
||||
deferred := 0
|
||||
if maxPerRun > 0 && len(pending) > maxPerRun {
|
||||
deferred = len(pending) - maxPerRun
|
||||
pending = pending[:maxPerRun]
|
||||
}
|
||||
if dryRun {
|
||||
for oldPath := range m.PendingTakedowns {
|
||||
for _, oldPath := range pending {
|
||||
results = append(results, DirectResult{Type: resultType, Path: oldPath, Status: "planned"})
|
||||
}
|
||||
if deferred > 0 {
|
||||
results = append(results, DirectResult{Type: "info", Status: "skipped",
|
||||
Error: fmt.Sprintf("還有 %d 筆待撤,下一輪接著撤", deferred)})
|
||||
}
|
||||
return results, exit
|
||||
}
|
||||
// #153:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件——
|
||||
@@ -1144,7 +1242,13 @@ func drainPendingTakedowns(
|
||||
if resultType == "folder_takedown" {
|
||||
step = stepRetire
|
||||
}
|
||||
for oldPath, pageName := range m.PendingTakedowns {
|
||||
for _, oldPath := range pending {
|
||||
// #121:下架那條路正在退避 ⇒ 整批停手(清單是持久的,下一輪接著撤)。
|
||||
if note := cfg.routeNote(cfg.triggerURL(cfg.RemovedWF)); note != "" {
|
||||
results = append(results, DirectResult{Type: "info", Status: "skipped", Error: note})
|
||||
break
|
||||
}
|
||||
pageName := m.PendingTakedowns[oldPath]
|
||||
pace()
|
||||
res := DirectResult{Type: resultType, Path: oldPath}
|
||||
mach := cfg.machineIdentity()
|
||||
@@ -1179,6 +1283,10 @@ func drainPendingTakedowns(
|
||||
saveManifest()
|
||||
results = append(results, res)
|
||||
}
|
||||
if deferred > 0 {
|
||||
results = append(results, DirectResult{Type: "info", Status: "skipped",
|
||||
Error: fmt.Sprintf("還有 %d 筆待撤,下一輪接著撤", deferred)})
|
||||
}
|
||||
return results, exit
|
||||
}
|
||||
|
||||
@@ -1256,7 +1364,7 @@ func retireRootOnce(cfg *DirectConfig, root string, dryRun bool) (
|
||||
}
|
||||
}
|
||||
dr, de := drainPendingTakedowns(cfg, m, absRoot, "folder_takedown",
|
||||
"移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest)
|
||||
"移除資料夾後的雲端撤除失敗(下輪重試):", pace, dryRun, saveManifest, 0)
|
||||
results = append(results, dr...)
|
||||
exit = de
|
||||
|
||||
@@ -1499,8 +1607,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// payload.AllExcludedDirs/plan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries**
|
||||
//(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。
|
||||
// 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。
|
||||
//
|
||||
// StampMachine(`inkstone/Arcrun#180`):蓋上「這棵樹是哪一台機器算的」。蓋在這裡
|
||||
// ——build 之後、Publish 與 sync 之前——所以**本機快照與上雲酬載必然是同一份身分**。
|
||||
// 卡片那條路(folderindex/inventory/sourcerepair)用的是同一個 cfg.machineIdentity(),
|
||||
// 同一輪只解析一次,兩條路送上去的值必然相同。
|
||||
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
|
||||
payload.AllExcludedDirs, plan, runNow)
|
||||
payload.AllExcludedDirs, plan, runNow).StampMachine(cfg.machineIdentity())
|
||||
if !dryRun {
|
||||
PublishFolderTreeNow(cfg.Manifest, root, tree, runNow)
|
||||
}
|
||||
@@ -1635,6 +1748,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 🔴 `inkstone/arcrun-rag#121`:這個檔要送去的那條雲端路正在退避 ⇒ 連萃取都不做。
|
||||
// 與上面兩道閘同一層、同一個理由:路壞了不是這個檔的錯,不記 FailCount;
|
||||
// 而且先擋在萃取之前——送不出去的卡,萃了只是白燒一份 AI 額度。
|
||||
{
|
||||
routeURL := cfg.triggerURL(cfg.IngestWF)
|
||||
if cfg.Extractor != "" {
|
||||
routeURL = cfg.triggerURL(cfg.CardIngestWF)
|
||||
}
|
||||
if note := cfg.routeNote(routeURL); note != "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = note
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 萃取本身也是一條路(workers-ai 打的正是這台知識庫的 /portal/daemon/extract),
|
||||
// 2026-09-13 真機上打最多的就是它——同一道閘。gemma 打 Google,不在這裡。
|
||||
if cfg.Extractor == "workers-ai" {
|
||||
if note := cfg.routeNote(workersAIExtractURL(cfg.CypherURL)); note != "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = note
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
||||
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
||||
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
||||
@@ -1697,7 +1835,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
||||
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
||||
xgate := cfg.openGate(stepExtractDoc)
|
||||
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin)
|
||||
prior := m.Entries[ev.Path] // #121:先前失敗過的檔再失敗,不算「路壞了」
|
||||
cards, xerr = extractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin,
|
||||
prior != nil && prior.FailCount > 0)
|
||||
xgate.release()
|
||||
if xerr == nil {
|
||||
xgate.ok()
|
||||
@@ -1731,6 +1871,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
routeSkipped := false // #121:卡是被「路在退避」擋下的(沒打出去)
|
||||
// InkStoneCo#44 ④(2026-08-15):gemma 路現在一份文件產「文件卡+N 張
|
||||
// 概念卡」(cards[0]=文件卡)。雲端 rag_ingest_card 以 page_name upsert、
|
||||
// 下架以原稿頁名比對 ⇒ N 張卡都送會互相蓋寫同一頁。
|
||||
@@ -1783,10 +1924,17 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
cardBody["quality"] = "low"
|
||||
cardBody["quality_warnings"] = warns
|
||||
}
|
||||
status, _, perr := cfg.postJSON(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody)
|
||||
// #121:這個檔先前失敗過 ⇒ 這一發再失敗不算「路壞了」(見 routebackoff.go 檔頭)。
|
||||
prior := m.Entries[ev.Path]
|
||||
status, _, perr := cfg.postJSONAs(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody,
|
||||
prior != nil && prior.FailCount > 0)
|
||||
res.HTTPStatus = status
|
||||
if perr != nil {
|
||||
res.Status, res.Error = "failed", perr.Error()
|
||||
if isRouteBackoff(perr) {
|
||||
res.Status = "skipped" // #121:沒打出去,不是這個檔的失敗
|
||||
routeSkipped = true
|
||||
}
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
@@ -1802,6 +1950,8 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
m.MarkNoCloudCard(ev.Path)
|
||||
}
|
||||
qs.DailyCount++ // 2026-08-07:今天的成就數(額度訊息「今天已經幫你整理了 N 份」用)
|
||||
} else if routeSkipped {
|
||||
// #121:這一發根本沒打出去 ⇒ 不記病歷(同額度冷卻/帳號沒回應的處理)。
|
||||
} else {
|
||||
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
|
||||
//(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。
|
||||
@@ -1853,6 +2003,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// #121:下架那條路正在退避 ⇒ 這輪不打,維持「暫時放回」,下一輪自然重試。
|
||||
if note := cfg.routeNote(cfg.triggerURL(cfg.RemovedWF)); note != "" {
|
||||
res.Status, res.Error = "skipped", note
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
pace() // 2026-08-07:下架一樣是觸發雲端 workflow,同樣節流
|
||||
// 下架=POST {page_name, path} 進 rag_takedown_direct(按 page_name 讀 kbdb blocks
|
||||
// 標 deprecated,不碰 R2;獨立於 rag_ingest 的 __CARDS_PREFIX__ 閘——direct 模式檔在
|
||||
@@ -1914,7 +2070,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。
|
||||
// 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。
|
||||
dr, de := drainPendingTakedowns(cfg, m, absRoot, "renamed_takedown",
|
||||
"改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest)
|
||||
"改名/搬移後舊頁下架失敗(下輪重試):", pace, dryRun, saveManifest, perRunCap)
|
||||
results = append(results, dr...)
|
||||
if de != 0 {
|
||||
exit = de
|
||||
|
||||
+16
-1
@@ -96,6 +96,19 @@ func tooBigForWorkersAI(srcText, relPath string) string {
|
||||
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
|
||||
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
|
||||
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin) ([]string, error) {
|
||||
return extractWithWorkersAI(cypherURL, apiKey, absRoot, relPath, origin, false)
|
||||
}
|
||||
|
||||
// workersAIExtractURL=萃取端點。探測(probe_workersai.go)與萃取打的是同一條路,
|
||||
// 退避(routebackoff.go)也用這一個網址當鍵。
|
||||
func workersAIExtractURL(cypherURL string) string {
|
||||
return strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract"
|
||||
}
|
||||
|
||||
// extractWithWorkersAI=ExtractWithWorkersAI,多帶 retry:這個檔先前就失敗過。
|
||||
// `inkstone/arcrun-rag#121`:這條路不經 postJSON,所以在這裡自己把結果記進路由退避——
|
||||
// 2026-09-13 真機上對 youlin 打最多的正是這一條。
|
||||
func extractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin, retry bool) ([]string, error) {
|
||||
if strings.TrimSpace(cypherURL) == "" {
|
||||
return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_url(config)")
|
||||
}
|
||||
@@ -130,7 +143,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin Sou
|
||||
"prompt": wikiExtractPrompt(pageName, srcText),
|
||||
})
|
||||
|
||||
url := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/") + "/portal/daemon/extract"
|
||||
url := workersAIExtractURL(cypherURL)
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,9 +153,11 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin Sou
|
||||
|
||||
resp, err := workersAIHTTP.Do(req)
|
||||
if err != nil {
|
||||
cloudRoutes.record(url, directNow(), 0, err, retry) // #121
|
||||
return nil, fmt.Errorf("連不上你的知識庫:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
cloudRoutes.record(url, directNow(), resp.StatusCode, nil, retry) // #121:5xx/429 記失敗,2xx 歸零
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFolderProgressLandsInStatus 守住 `inkstone/arcrun-rag#159` 的接線:
|
||||
// **逐資料夾的同步現況真的會寫進 status.json**,而且是每個根各一份。
|
||||
//
|
||||
// 為什麼要有這一條:小幫手畫面上那個打勾完全靠這個欄位。它沒寫出來的話,
|
||||
// 前端拿不到資料 ⇒ 每一列都會落到 unknown ⇒ 使用者又回到「看不出同步了沒」。
|
||||
func TestFolderProgressLandsInStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
rootA := filepath.Join(base, "A")
|
||||
rootB := filepath.Join(base, "B")
|
||||
for _, d := range []string{rootA, rootB} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(rootA, "a1.md"), []byte("# A1"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootA, "a2.md"), []byte("# A2"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootB, "b1.md"), []byte("# B1"), 0o644)
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{rootA, rootB}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("讀不到 status.json:%v", err)
|
||||
}
|
||||
if len(st.FolderProgress) != 2 {
|
||||
t.Fatalf("folder_progress 應該每個看守資料夾各一份(2),got %d:%+v",
|
||||
len(st.FolderProgress), st.FolderProgress)
|
||||
}
|
||||
if got := st.FolderProgress[rootA].Total; got != 2 {
|
||||
t.Errorf("A 的分母應為 2,got %d", got)
|
||||
}
|
||||
if got := st.FolderProgress[rootB].Total; got != 1 {
|
||||
t.Errorf("B 的分母應為 1,got %d", got)
|
||||
}
|
||||
// 🔴 逐資料夾加起來要等於首頁那行總量——它們本來就是同一組數字被切開,
|
||||
// 對不起來就代表有人另算了一套(那正是這張票要拔掉的病)。
|
||||
var sum SyncProgress
|
||||
for _, p := range st.FolderProgress {
|
||||
sum = sum.Add(p)
|
||||
}
|
||||
if sum.Total != st.Progress.Total-st.Progress.Unreadable {
|
||||
t.Errorf("逐資料夾合計 %d ≠ 首頁總量 %d(扣掉沒進 manifest 的 %d)",
|
||||
sum.Total, st.Progress.Total, st.Progress.Unreadable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloudVersionSurvivesOneFailedProbe 守住票的追加條件 6:
|
||||
// **「連不上」的判準不能是「這一輪解析失敗」。**
|
||||
//
|
||||
// leo 2026-08-28 畫面上寫「目前連不上這個知識庫,查不到版本」,
|
||||
// 而同一台機器 curl 打同一支 /health 是 HTTP 200——那是機器當下 DNS 抽風。
|
||||
// 一次探測失敗不准抹掉我們已經知道的版本。
|
||||
func TestCloudVersionSurvivesOneFailedProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "R")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(root, "r.md"), []byte("# R"), 0o644)
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
|
||||
orig := fetchCloudVersion
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
// 「最新版是多少」也要釘住,否則這條測試會依賴 install.arcrun.dev 通不通
|
||||
// ——那樣測的就不是我們要測的東西了(而且 CI 離線就紅)。
|
||||
origLatest := fetchLatestCloudReleaseRaw
|
||||
fetchLatestCloudReleaseRaw = func() (string, bool) { return "1.4.58", true }
|
||||
latestMu.Lock()
|
||||
latestFetched = time.Time{} // 清掉節流快取,讓 stub 這一輪真的生效
|
||||
latestMu.Unlock()
|
||||
defer func() {
|
||||
fetchLatestCloudReleaseRaw = origLatest
|
||||
latestMu.Lock()
|
||||
latestFetched = time.Time{}
|
||||
latestMu.Unlock()
|
||||
}()
|
||||
|
||||
// 第一輪:查得到版本。
|
||||
fetchCloudVersion = func(string) (string, bool) { return "1.4.60", true }
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
// 第二輪:探測失敗(模擬 DNS 抽風)。
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.AccountDetails) != 1 {
|
||||
t.Fatalf("應該只有一個帳號,got %d", len(st.AccountDetails))
|
||||
}
|
||||
for host, acc := range st.AccountDetails {
|
||||
if acc.CloudVersion != "1.4.60" {
|
||||
t.Errorf("%s:一次探測失敗就把版本抹掉了(got %q,應沿用 1.4.60)", host, acc.CloudVersion)
|
||||
}
|
||||
if !acc.CloudUpdateKnown {
|
||||
t.Errorf("%s:版本明明還知道,卻標成「查不到」", host)
|
||||
}
|
||||
// 可達性是另一件事,要照實記——不准為了讓畫面好看就說連得上。
|
||||
if acc.CloudCheckOK {
|
||||
t.Errorf("%s:這一輪明明沒連上,cloud_check_ok 卻是 true", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -258,14 +258,18 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
|
||||
}
|
||||
lib := cfg.libraryFor(absRoot)
|
||||
cards := BuildFolderCards(absRoot, m.Entries, lib)
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
if m.FolderCardHashes == nil {
|
||||
m.FolderCardHashes = map[string]string{}
|
||||
}
|
||||
|
||||
// 先刪掉「這輪已經不存在的資料夾」的記帳,避免雜湊表無限長大。
|
||||
//
|
||||
// 🔴 arcrun-rag#104(comment 6309):以前這裡**只刪本機的記帳**,雲端那張
|
||||
// 「資料夾:KB/logseq/bak/pages/…」的卡沒有人去撤——leo21c 的 `kb` 庫核心實體裡就有
|
||||
// 「資料夾:KB/logseq/bak/system-dev/wiki/cards」(degree 44)、`part_of` 三元組 3,610 條,
|
||||
// 全是已經不該存在的資料夾留下的。現在資料夾從樹上消失=那張卡排進待撤清單,
|
||||
// 走跟改名同一條下架路(drainPendingTakedowns,成功一筆清一筆,失敗下輪重試)。
|
||||
// 這一段刻意放在「一張卡都沒有」的提早返回**之前**——整棵樹都不收了才更要撤。
|
||||
live := map[string]bool{}
|
||||
for _, c := range cards {
|
||||
live[c.Rel] = true
|
||||
@@ -273,8 +277,12 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
|
||||
for rel := range m.FolderCardHashes {
|
||||
if !live[rel] {
|
||||
delete(m.FolderCardHashes, rel)
|
||||
m.QueueTakedown(folderCardPath(lib, rel), folderCardPageName(absRoot, rel))
|
||||
}
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 🔴 先挑「這輪真的要送的」,**再**套上限——順序反過來就會變成永遠只重試前 200 個,
|
||||
// 後面的資料夾一輩子送不上去(而且畫面上看起來一切正常)。
|
||||
@@ -308,6 +316,10 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
|
||||
if wf == "" {
|
||||
wf = "rag_ingest_card"
|
||||
}
|
||||
// #121:收卡那條路正在退避 ⇒ 這輪不送(雜湊沒記,下一輪自然補送)。
|
||||
if !dryRun && cfg.routeNote(cfg.triggerURL(wf)) != "" {
|
||||
return nil
|
||||
}
|
||||
mach := cfg.machineIdentity()
|
||||
for _, t := range todo {
|
||||
c := t.card
|
||||
|
||||
@@ -149,6 +149,41 @@ type FolderTree struct {
|
||||
Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來
|
||||
TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
|
||||
// Machine/MachineLabel=**這棵樹是哪一台機器報上來的**(`inkstone/Arcrun#180`)。
|
||||
//
|
||||
// 🔴 為什麼非有不可:雲端的庫清單是「總庫 → 機器 → 資料夾」三層。樹的酬載裡沒有
|
||||
// 這一格時,**每一個庫都只能掛在「未知來源」底下** ⇒ leo 2026-08-28 看到 16 個庫
|
||||
// 全擠在同一個未知節點下,第一反應是「這是把別的帳號同步的資料夾外洩了?」。
|
||||
// 那個驚嚇是畫面造成的,而畫面之所以說不出來,是因為**上行酬載根本沒送**。
|
||||
//
|
||||
// 🔴 **不是新發明的東西**:機器身分早就存在(`machine.json`/`ResolveMachine`),
|
||||
// 卡片那條路也早就在送了(`folderindex.go`/`inventory.go`/`sourcerepair.go`
|
||||
// 的 `"machine"`/`"machine_label"` 兩欄)。這裡**照那條路走**,欄名一字不差,
|
||||
// 收端才不必為了樹另認一組欄位。
|
||||
//
|
||||
// Machine=比對鍵(鑄好不變);MachineLabel=顯示名(使用者可在 config 改)。
|
||||
// 兩格分開的理由見 machineid.go:改名不該讓庫裡憑空多出一台機器。
|
||||
//
|
||||
// 🔴 這兩格**進 Hash()**(不像 GeneratedAt 那樣被剔掉):使用者改了 machine_label
|
||||
// 之後,雲端要看得到新名字 ⇒ 內容雜湊必須跟著變,否則冪等閘會讓它永遠不再送。
|
||||
Machine string `json:"machine,omitempty"`
|
||||
MachineLabel string `json:"machine_label,omitempty"`
|
||||
}
|
||||
|
||||
// StampMachine 蓋上「這棵樹是哪一台機器算的」,回一份新的樹(不改原件)。
|
||||
//
|
||||
// 🔴 為什麼是「蓋章」而不是塞進 BuildFolderTree 的參數:BuildFolderTree 是純函式,
|
||||
// 吃的全是「這個資料夾長什麼樣」的事實;機器身分是**這台電腦是誰**,是另一回事。
|
||||
// 分開之後,樹的既有測試不必為了機器身分多餵一份假身分。
|
||||
//
|
||||
// 🔴 為什麼要蓋在樹上、而不是送出時才從 cfg 拿:本機快照(folder-trees.json,
|
||||
// 桌面小幫手讀的那份)與上雲的酬載**是同一個 FolderTree**。蓋在樹上,兩邊必然一致;
|
||||
// 送出時才拿,本機那份就永遠是空的——而「本機那份有沒有 machine」正是這次的檢查點。
|
||||
func (t FolderTree) StampMachine(m MachineIdentity) FolderTree {
|
||||
t.Machine = m.ID
|
||||
t.MachineLabel = m.Label
|
||||
return t
|
||||
}
|
||||
|
||||
// BuildFolderTree 把「走訪時數出來的分母」與「manifest 現況的分子」合成一棵樹。
|
||||
@@ -354,6 +389,12 @@ func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderT
|
||||
"generated_at": tree.GeneratedAt,
|
||||
"sync_token": h,
|
||||
"nodes": tree.Nodes,
|
||||
// 🔴 機器身分(`inkstone/Arcrun#180`):欄名與卡片那條路一字不差
|
||||
// (`folderindex.go`/`inventory.go`/`sourcerepair.go`),收端不必另認一組。
|
||||
// 值取自 `tree`(已由 StampMachine 蓋章)而不是這裡再問一次 cfg——
|
||||
// 本機快照與上雲酬載共用同一個 FolderTree,一個來源就不會漂。
|
||||
"machine": tree.Machine,
|
||||
"machine_label": tree.MachineLabel,
|
||||
}
|
||||
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
|
||||
res.HTTPStatus = status
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package collector
|
||||
|
||||
// foldertree_machine_test.go — 資料夾樹要說得出「**是哪一台機器報上來的**」
|
||||
//(`inkstone/Arcrun#180`)。
|
||||
//
|
||||
// 這張票的現象是 leo 2026-08-28 的原話:
|
||||
//
|
||||
// 「youlin 從桌面同步 4 個資料夾,雲端顯示 16 個,**這是把別的帳號同步的資料夾外洩了?**」
|
||||
//
|
||||
// 實查後不是外洩(那 17 個庫全是他自己歷史上同步過的),但**畫面說不出來**:
|
||||
// 雲端的庫清單是「總庫 → 機器 → 資料夾」三層,而樹的上行酬載裡沒有機器身分
|
||||
// ⇒ 每一個庫都只能掛在「未知來源」底下 ⇒ 看起來就像別人的東西。
|
||||
//
|
||||
// 🔴 本檔守的是「**送出去的東西身上有沒有這一格**」,不是畫面長怎樣(那在 matrix/arcrun)。
|
||||
// 三個檢查點,各對應一種會靜默壞掉的方式:
|
||||
//
|
||||
// ① 酬載欄名要與卡片那條路一字不差 —— 收端不必為了樹另認一組欄位
|
||||
// ② 改名要算成「內容變了」 —— 否則冪等閘會讓新名字永遠送不出去
|
||||
// ③ 真的跑一輪,兩邊都要有 —— 有人在別處新增 BuildFolderTree 呼叫卻忘了蓋章時,這條會紅
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ① 酬載欄名:與 folderindex.go/inventory.go/sourcerepair.go 送卡片時完全相同。
|
||||
func TestSyncFolderTree酬載帶得出機器身分(t *testing.T) {
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "note.md"), "# note")
|
||||
tree := buildTreeFromDisk(t, root).StampMachine(MachineIdentity{
|
||||
ID: "youlinhsieh@Leo-MBA",
|
||||
Label: "教育部 Leo 的 Mac",
|
||||
})
|
||||
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
res := syncFolderTree(cfg, root, m, tree, false, time.Unix(1786900000, 0))
|
||||
if res == nil || res.Status != "ingested" {
|
||||
t.Fatalf("應送達:%+v", res)
|
||||
}
|
||||
|
||||
// 🔴 欄名寫死在這裡是刻意的:收端(cypher-executor 的 /portal/daemon/folder-tree)
|
||||
// 認的就是這兩個字串。改欄名=改協定,這條測試就是那道閘。
|
||||
if got["machine"] != "youlinhsieh@Leo-MBA" {
|
||||
t.Errorf("酬載的 machine(比對鍵)不對:%v", got["machine"])
|
||||
}
|
||||
if got["machine_label"] != "教育部 Leo 的 Mac" {
|
||||
t.Errorf("酬載的 machine_label(顯示名)不對:%v", got["machine_label"])
|
||||
}
|
||||
// 比對鍵與顯示名**必須是兩格**:只送一格的話,使用者改名就等於雲端多出一台機器。
|
||||
if got["machine"] == got["machine_label"] {
|
||||
t.Error("改過名的機器,比對鍵與顯示名不該相同——有一格被另一格頂替了")
|
||||
}
|
||||
}
|
||||
|
||||
// ② 改名要算成「內容變了」。
|
||||
//
|
||||
// 這是本檔最容易被忽略的一格:`Hash()` 是冪等閘的唯一判準,機器身分若沒進雜湊,
|
||||
// 使用者在 config.json 改了 `machine_label` 之後,樹的內容沒動 ⇒ 永遠不再送
|
||||
// ⇒ **雲端會一直顯示舊名字,而且沒有任何機制會發現**。
|
||||
func TestFolderTreeHash把機器改名算成內容變了(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "note.md"), "# note")
|
||||
base := buildTreeFromDisk(t, root)
|
||||
|
||||
before := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "youlinhsieh@Leo-MBA"})
|
||||
after := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "教育部 Leo 的 Mac"})
|
||||
|
||||
if before.Hash() == after.Hash() {
|
||||
t.Error("改了顯示名雜湊卻沒變——冪等閘會讓新名字永遠送不上去")
|
||||
}
|
||||
// 沒蓋章的樹與蓋了章的樹也必須不同,否則升級到本版的機器不會補送一次。
|
||||
if base.Hash() == before.Hash() {
|
||||
t.Error("蓋上機器身分之後雜湊沒變——舊機器升級後不會補送,雲端永遠是「未知來源」")
|
||||
}
|
||||
// 但時間仍然不進雜湊(既有慣例,不准被這次改動弄壞)。
|
||||
later := before
|
||||
later.GeneratedAt = before.GeneratedAt + 999
|
||||
if later.Hash() != before.Hash() {
|
||||
t.Error("時間跑進雜湊了——那等於沒有冪等,每輪都會重送整棵樹")
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 真的跑一輪:本機快照與上雲酬載**是同一份身分**。
|
||||
//
|
||||
// 🔴 這條守的是「有人忘了蓋章」。`StampMachine` 蓋在 direct.go 的 BuildFolderTree
|
||||
// 呼叫點上;日後若有人在別處另起一個呼叫、或把蓋章那一行刪掉,
|
||||
// 前兩條測試(各自手動蓋章)照樣會綠,只有這條會紅。
|
||||
func TestRunDirectOnce的樹一路帶著機器身分(t *testing.T) {
|
||||
var treeBody 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") {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &treeBody)
|
||||
}
|
||||
_ = 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內容")
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
manifest := filepath.Join(base, "manifest.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifest,
|
||||
MachineLabel: "教育部 Leo 的 Mac", // 使用者改過名 ⇒ 比對鍵與顯示名不同,兩格才分得出來
|
||||
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)
|
||||
}
|
||||
|
||||
// 這一輪實際解析出來的身分(machine.json 就鑄在 manifest 旁邊)。
|
||||
want := cfg.machineIdentity()
|
||||
if want.ID == "" {
|
||||
t.Fatal("這台機器連身分都沒鑄出來——machineid.go 那條線先壞了")
|
||||
}
|
||||
|
||||
// 本機快照(桌面小幫手讀的那份)
|
||||
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 tree.Machine != want.ID || tree.MachineLabel != want.Label {
|
||||
t.Errorf("本機快照沒蓋機器身分:machine=%q label=%q(應為 %q/%q)",
|
||||
tree.Machine, tree.MachineLabel, want.ID, want.Label)
|
||||
}
|
||||
|
||||
// 上雲酬載
|
||||
if treeBody == nil {
|
||||
t.Fatal("這一輪沒有把樹送上去——沒東西可驗")
|
||||
}
|
||||
if treeBody["machine"] != want.ID || treeBody["machine_label"] != want.Label {
|
||||
t.Errorf("上雲酬載沒帶機器身分:%v/%v(應為 %q/%q)",
|
||||
treeBody["machine"], treeBody["machine_label"], want.ID, want.Label)
|
||||
}
|
||||
// 🔴 兩邊必須是同一份,不是各算各的(本 repo 對「第二份實作」的一貫紅線)。
|
||||
if treeBody["machine"] != tree.Machine || treeBody["machine_label"] != tree.MachineLabel {
|
||||
t.Error("本機快照與上雲酬載的機器身分不一致——有一邊自己另算了一份")
|
||||
}
|
||||
// 使用者改過名 ⇒ 顯示名該是他設的那個,比對鍵不該跟著變。
|
||||
if want.Label != "教育部 Leo 的 Mac" {
|
||||
t.Errorf("config 的 machine_label 沒被採用:%q", want.Label)
|
||||
}
|
||||
if want.ID == want.Label {
|
||||
t.Error("改名把比對鍵也改掉了——那會讓雲端以為多出一台機器")
|
||||
}
|
||||
}
|
||||
@@ -521,6 +521,18 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) {
|
||||
if ambiguousBuildDirNames[name] && looksGenerated(absPath) {
|
||||
return true, "這是建置工具產生的目錄(旁邊就是產生它的專案檔)"
|
||||
}
|
||||
// ④ 筆記軟體自己的資料夾(`inkstone/arcrun-rag#104` comment 6309,leo 2026-09-05:「bak 是不要的」)。
|
||||
// Logseq graph 底下的 `logseq/` 裝的是它的設定(config.edn)、每次編輯自動留的備份
|
||||
// (`bak/`)與版本檔(`version-files/`)——**筆記本體在 `pages/`/`journals/`**。
|
||||
// 實據(leo21c,2026-09-05):`~/Documents/KB` 4,193 份文件裡 **3,620 份**是 `logseq/bak/`
|
||||
// 的殘影,同一頁的每一個歷史版本都被當成一份新知識收進 `kb`;08-13 早就判過是垃圾
|
||||
// (48 萬筆廢資料的三重放大之一),D82 重灌後又吃回來。
|
||||
// 判準用 vault.go 既有的那一套(要有佐證才算 Logseq graph):一個碰巧叫 `logseq`
|
||||
// 的普通資料夾(旁邊沒有 pages/journals/config.edn)照收——漏判只是多收一個資料夾,
|
||||
// 誤判是把使用者的東西弄不見。
|
||||
if name == "logseq" && logseqCorroborated(filepath.Dir(absPath)) {
|
||||
return true, "這是 Logseq 自己的設定與備份資料夾(bak/version-files),你的筆記本體在 pages 與 journals 裡,備份不是新知識"
|
||||
}
|
||||
if IsLinkedWorktree(absPath) {
|
||||
return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複"
|
||||
}
|
||||
@@ -531,6 +543,47 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// ExcludesPathWhy 回答「這個檔**在現在的策略下**收不收」——給 Scan 判斷「manifest 裡有、
|
||||
// 現況卻沒走到」的路徑用:它是**真的不見了**(資料夾沒掛載、同步半途),還是
|
||||
// **我們自己決定不再收了**(策略變了、排除規則補了一條)。
|
||||
//
|
||||
// 🔴 為什麼要分(`inkstone/arcrun-rag#104` comment 6309):補上「`logseq/` 不收」那一條之後,
|
||||
// leo21c 的 `~/Documents/KB` manifest 裡 4,193 份有 3,620 份會在同一輪從現況消失——
|
||||
// 86%,遠超 mass_delete_guard 的 40%。那道閘擋的是「資料夾未掛載」這種**意外**;
|
||||
// 策略排除是**確定的決定**,被它擋住的結果是:警告每輪都響、殘影永遠留在雲端、
|
||||
// 而且畫面上看起來像資料夾壞了。分開之後:意外照擋,決定照下架。
|
||||
//
|
||||
// 回 (true, 理由)=這條路徑上有一層被 SkipsDirWhy 剪掉、或檔案本身被 KeepsFile 拒收;
|
||||
// 理由就是講給使用者聽的那一句。走訪器自己擋的隱藏目錄(`.git`、`.obsidian`…)也算。
|
||||
func (p IngestPlan) ExcludesPathWhy(relSlash, absRoot string) (bool, string) {
|
||||
relSlash = strings.ReplaceAll(relSlash, "\\", "/")
|
||||
segs := strings.Split(relSlash, "/")
|
||||
dir := ""
|
||||
for _, seg := range segs[:len(segs)-1] {
|
||||
if dir == "" {
|
||||
dir = seg
|
||||
} else {
|
||||
dir += "/" + seg
|
||||
}
|
||||
if strings.HasPrefix(seg, ".") {
|
||||
return true, "這是隱藏資料夾,不收"
|
||||
}
|
||||
if skip, why := p.SkipsDirWhy(dir, filepath.Join(absRoot, filepath.FromSlash(dir))); skip {
|
||||
return true, why
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(segs[len(segs)-1], ".") {
|
||||
return true, "這是隱藏檔,不收"
|
||||
}
|
||||
if !p.KeepsFile(relSlash) {
|
||||
if _, why := p.CollectsDirWhy(folderOfRel(relSlash)); why != "" {
|
||||
return true, why
|
||||
}
|
||||
return true, "這一層的檔案不在這次的收檔範圍裡"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// CollectsDirWhy 回答「這一層的檔案,這次收不收」——**走訪照走,只影響收檔**。
|
||||
//
|
||||
// 回 (false, 理由) = 這一層的檔不收,理由講給使用者聽;(true, "") = 照收檔規則走。
|
||||
|
||||
+5
-1
@@ -265,11 +265,15 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr
|
||||
res.Status = "planned"
|
||||
return res
|
||||
}
|
||||
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
|
||||
wf := cfg.CardIngestWF
|
||||
if wf == "" {
|
||||
wf = "rag_ingest_card"
|
||||
}
|
||||
// #121:收卡那條路正在退避 ⇒ 這輪不送,也不記失敗(這一發根本沒打出去)。
|
||||
if cfg.routeNote(cfg.triggerURL(wf)) != "" {
|
||||
return nil
|
||||
}
|
||||
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
|
||||
// machine(`inkstone/mira#6`):總覽卡的 path 是**合成**的(inventoryCardPath),
|
||||
// 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把
|
||||
// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package collector
|
||||
|
||||
// logseq_bak_residue_test.go — `inkstone/arcrun-rag#104` comment 6309(leo 2026-09-05「bak 是不要的」)。
|
||||
//
|
||||
// 病:Logseq 每次編輯都往 `logseq/bak/` 留一份備份,daemon 把每一份都當新知識收進 `kb`
|
||||
// ——leo21c 的 KB 4,193 份文件裡 3,620 份是這種殘影,而且雲端的資料夾卡也跟著長出
|
||||
// 「資料夾:KB/logseq/bak/pages/…」上千張。08-13 早判過是垃圾,D82 重灌後回歸。
|
||||
//
|
||||
// 三個環,缺一個殘影都清不掉:
|
||||
// ① 不再收:`logseq/` 是 Logseq 自己的資料夾,整棵跳過,理由看得見
|
||||
// ② 已收的要下架:策略不再收的檔照發 removed,**不受**大量刪除防呆管(86% 會被擋一輩子)
|
||||
// ③ 資料夾卡也要撤:資料夾從樹上消失=那張卡排進待撤清單
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeLogseqGraph 造一個有佐證的 Logseq graph:pages/journals 是筆記本體,
|
||||
// logseq/ 底下是設定與備份。回傳根與 bak 檔的相對路徑。
|
||||
func makeLogseqGraph(t *testing.T, withConfig bool) (root string, bakFiles []string) {
|
||||
t.Helper()
|
||||
root = t.TempDir()
|
||||
writeFile(t, root, "pages/index.md", "- n8n 本機安裝指南\n", baseTime)
|
||||
writeFile(t, root, "pages/擋風防雨車.md", "- Adiva D1 價格 20 萬\n", baseTime)
|
||||
writeFile(t, root, "journals/2026_09_04.md", "- 今天評估 Adiva D1\n", baseTime)
|
||||
if withConfig {
|
||||
writeFile(t, root, "logseq/config.edn", "{:meta/version 1}\n", baseTime)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
"logseq/bak/pages/index/2026-09-01T12_08_05.467Z.Desktop.md",
|
||||
"logseq/bak/pages/index/2026-09-01T13_07_56.194Z.Desktop.md",
|
||||
"logseq/bak/pages/擋風防雨車/2026-09-04T01_00_00.000Z.Desktop.md",
|
||||
"logseq/bak/journals/2026_09_04/2026-09-04T02_00_00.000Z.Desktop.md",
|
||||
"logseq/version-files/base/pages/index.md",
|
||||
} {
|
||||
writeFile(t, root, rel, "- 舊版殘影 "+rel+"\n", baseTime)
|
||||
bakFiles = append(bakFiles, rel)
|
||||
}
|
||||
return root, bakFiles
|
||||
}
|
||||
|
||||
// ① Logseq graph 底下的 logseq/ 整棵不收,而且理由要講得出來。
|
||||
func TestPlanIngest_Logseq自己的bak資料夾不收且理由看得見(t *testing.T) {
|
||||
root, bakFiles := makeLogseqGraph(t, true)
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
|
||||
if plan.Mode != IngestAll {
|
||||
t.Fatalf("筆記庫應該是 all 模式,實得 %s", plan.Mode)
|
||||
}
|
||||
got := eventPaths(payload)
|
||||
for _, p := range got {
|
||||
if strings.HasPrefix(p, "logseq/") {
|
||||
t.Fatalf("收到了 Logseq 自己的檔:%s(全部:%v)", p, got)
|
||||
}
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("筆記本體應該收 3 份(pages×2+journals×1),實得 %d:%v", len(got), got)
|
||||
}
|
||||
var why string
|
||||
for _, d := range payload.AllExcludedDirs {
|
||||
if d.Path == "logseq" {
|
||||
why = d.Reason
|
||||
}
|
||||
}
|
||||
if why == "" {
|
||||
t.Fatalf("logseq/ 被跳過了,卻沒有列在排除清單裡——使用者會以為東西不見了:%+v", payload.AllExcludedDirs)
|
||||
}
|
||||
if !strings.Contains(why, "Logseq") || !strings.Contains(why, "bak") {
|
||||
t.Fatalf("理由要講得出這是 Logseq 的備份:%q", why)
|
||||
}
|
||||
// 樹上不該有 logseq/bak 這一層的節點——它整棵沒走進去,不是「走進去但不收」。
|
||||
for rel := range payload.DirStats {
|
||||
if strings.HasPrefix(rel, "logseq") {
|
||||
t.Fatalf("logseq/ 底下不該生出節點:%s", rel)
|
||||
}
|
||||
}
|
||||
_ = bakFiles
|
||||
}
|
||||
|
||||
// ①' 一個碰巧叫 logseq 的普通資料夾(旁邊沒有 pages/journals/config.edn)照收——
|
||||
// 漏判只是多收一個資料夾,誤判是把使用者的東西弄不見。
|
||||
func TestPlanIngest_碰巧叫logseq的普通資料夾照收(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "筆記.md", "- 一般筆記\n", baseTime)
|
||||
writeFile(t, root, "logseq/匯出/教學.md", "- 從 Logseq 匯出的教學\n", baseTime)
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
if len(got) != 2 || got[0] != "logseq/匯出/教學.md" {
|
||||
t.Fatalf("沒有佐證的 logseq 資料夾應該照收,實得 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 已經收過的殘影:規則補上之後要下架,而且不受大量刪除防呆管。
|
||||
//
|
||||
// 情境照 leo21c 的形狀:先用「看不出是 Logseq graph」的狀態把 bak 收進去(模擬舊版 daemon),
|
||||
// 再補上 config.edn(佐證齊了)——3,620/4,193=86% 的檔在同一輪從現況消失。
|
||||
func TestScan_策略不再收的檔照下架且不觸發大量刪除防呆(t *testing.T) {
|
||||
root, bakFiles := makeLogseqGraph(t, false)
|
||||
// pages/journals 在,logseq/ 沒有 config.edn ⇒ 佐證仍成立(journals/ 或 pages/ 存在就算)。
|
||||
// 所以要模擬「舊版把 bak 收進去」得直接造 manifest,不能靠舊判準。
|
||||
m := newTestManifest()
|
||||
first := mustScan(t, root, m)
|
||||
for _, rel := range bakFiles {
|
||||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||||
h, err := hashFile(abs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()}
|
||||
}
|
||||
markIngested(m)
|
||||
if n := len(m.Entries); n != 3+len(bakFiles) {
|
||||
t.Fatalf("前置:manifest 應有 %d 份,實得 %d(第一輪事件 %d)", 3+len(bakFiles), n, len(first.Events))
|
||||
}
|
||||
|
||||
p := mustScan(t, root, m)
|
||||
|
||||
removed := eventsOfType(p, "removed")
|
||||
if len(removed) != len(bakFiles) {
|
||||
t.Fatalf("殘影 %d 份都要下架,實得 removed %d:%+v", len(bakFiles), len(removed), removed)
|
||||
}
|
||||
for _, ev := range removed {
|
||||
if !strings.HasPrefix(ev.Path, "logseq/") {
|
||||
t.Fatalf("下架的不該是筆記本體:%s", ev.Path)
|
||||
}
|
||||
}
|
||||
for _, w := range p.Warnings {
|
||||
if w.Code == "mass_delete_guard" {
|
||||
t.Fatalf("這不是資料夾未掛載,是我們自己不收了——防呆不該響:%+v", w)
|
||||
}
|
||||
}
|
||||
var retired *Warning
|
||||
for i := range p.Warnings {
|
||||
if p.Warnings[i].Code == "plan_retired" {
|
||||
retired = &p.Warnings[i]
|
||||
}
|
||||
}
|
||||
if retired == nil {
|
||||
t.Fatalf("下架了 %d 份卻沒告訴使用者為什麼(票上的紅線:不要讓用戶猜):%+v", len(removed), p.Warnings)
|
||||
}
|
||||
if retired.RemovedCount != len(bakFiles) || !strings.Contains(retired.Message, "Logseq") {
|
||||
t.Fatalf("警告要說出份數與理由:%+v", *retired)
|
||||
}
|
||||
for _, rel := range bakFiles {
|
||||
if _, still := m.Entries[rel]; still {
|
||||
t.Fatalf("殘影 %s 不該還留在 manifest(下架成功後 direct.go 才刪;Scan 這一層先拿掉)", rel)
|
||||
}
|
||||
}
|
||||
if len(m.Entries) != 3 {
|
||||
t.Fatalf("筆記本體 3 份要留著,實得 %d", len(m.Entries))
|
||||
}
|
||||
// 殘影的內容雜湊不准跟新檔配成 renamed——那會讓殘影換個名字留在雲端。
|
||||
writeFile(t, root, "pages/新頁.md", "- 舊版殘影 "+bakFiles[0]+"\n", baseTime)
|
||||
p2 := mustScan(t, root, m)
|
||||
if n := len(eventsOfType(p2, "renamed")); n != 0 {
|
||||
t.Fatalf("殘影不該被當成搬走的檔:%+v", p2.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// ②' 同一輪裡「策略不收」與「真的不見」要各管各的:前者照下架,後者仍受防呆管。
|
||||
func TestScan_策略不再收與真的不見同時發生時各管各的(t *testing.T) {
|
||||
root, bakFiles := makeLogseqGraph(t, false)
|
||||
m := newTestManifest()
|
||||
mustScan(t, root, m)
|
||||
for _, rel := range bakFiles {
|
||||
h, err := hashFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Entries[rel] = &ManifestEntry{ContentHash: h, Size: 1, Mtime: baseTime.Unix()}
|
||||
}
|
||||
markIngested(m)
|
||||
// 筆記本體 3 份全部消失(像資料夾沒掛載)。防呆的分母是整份 manifest(3+5=8),
|
||||
// 3/8=37.5%——刻意把門檻設在 30%,讓「真的不見的那 3 份」單獨就超過門檻,
|
||||
// 而殘影那 5 份不管多少都不該被算進去。
|
||||
for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} {
|
||||
if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed := eventsOfType(p, "removed")
|
||||
if len(removed) != len(bakFiles) {
|
||||
t.Fatalf("只有殘影該下架(%d),真的不見的 3 份要被防呆壓住;實得 %+v", len(bakFiles), removed)
|
||||
}
|
||||
codes := map[string]bool{}
|
||||
for _, w := range p.Warnings {
|
||||
codes[w.Code] = true
|
||||
}
|
||||
if !codes["mass_delete_guard"] || !codes["plan_retired"] {
|
||||
t.Fatalf("兩種警告都要有:%+v", p.Warnings)
|
||||
}
|
||||
for _, rel := range []string{"pages/index.md", "pages/擋風防雨車.md", "journals/2026_09_04.md"} {
|
||||
if _, kept := m.Entries[rel]; !kept {
|
||||
t.Fatalf("防呆觸發時真的不見的 %s 要留在 manifest 等下輪重評", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 資料夾從樹上消失 ⇒ 雲端那張資料夾卡排進待撤清單(以前只刪本機記帳,雲端永遠留著)。
|
||||
func TestFolderCard_消失的資料夾卡要排進待撤清單(t *testing.T) {
|
||||
root := "/x/KB"
|
||||
lib := "kb"
|
||||
stale := "logseq/bak/pages/index"
|
||||
m := &Manifest{
|
||||
Entries: ent("journals/2026_09_04.md"),
|
||||
FolderCardHashes: map[string]string{
|
||||
"journals": "sha256:old",
|
||||
stale: "sha256:old",
|
||||
},
|
||||
}
|
||||
cfg := &DirectConfig{Library: lib}
|
||||
syncFolderCards(cfg, root, m, true, true /*dryRun*/, time.Now())
|
||||
|
||||
wantPath := folderCardPath(lib, stale)
|
||||
page, queued := m.PendingTakedowns[wantPath]
|
||||
if !queued {
|
||||
t.Fatalf("消失的資料夾卡沒排進待撤清單:%+v", m.PendingTakedowns)
|
||||
}
|
||||
if page != "資料夾:KB/"+stale {
|
||||
t.Fatalf("撤的頁名要跟當初送上去的一樣:%q", page)
|
||||
}
|
||||
if _, still := m.FolderCardHashes[stale]; still {
|
||||
t.Fatal("本機記帳也要清掉")
|
||||
}
|
||||
if _, wrong := m.PendingTakedowns[folderCardPath(lib, "journals")]; wrong {
|
||||
t.Fatal("還在的資料夾不該被撤")
|
||||
}
|
||||
|
||||
// 整棵樹都不收了(一張卡都沒有)更要撤——這一段不能被「沒卡就提早返回」擋住。
|
||||
m2 := &Manifest{
|
||||
Entries: map[string]*ManifestEntry{},
|
||||
FolderCardHashes: map[string]string{stale: "sha256:old"},
|
||||
}
|
||||
syncFolderCards(cfg, root, m2, true, true, time.Now())
|
||||
if _, queued := m2.PendingTakedowns[wantPath]; !queued {
|
||||
t.Fatalf("樹空了也要撤:%+v", m2.PendingTakedowns)
|
||||
}
|
||||
}
|
||||
|
||||
// ③' 待撤清單套單輪上限:上千張資料夾卡不能一輪撤完(會撞單輪等待上限),沒撤到的下輪接著撤。
|
||||
func TestDrainPendingTakedowns_套單輪上限且順序固定(t *testing.T) {
|
||||
m := &Manifest{PendingTakedowns: map[string]string{}}
|
||||
for _, k := range []string{"c", "a", "b", "d", "e"} {
|
||||
m.QueueTakedown(k+".md", k)
|
||||
}
|
||||
cfg := &DirectConfig{}
|
||||
res, _ := drainPendingTakedowns(cfg, m, "/x", "renamed_takedown", "", func() {}, true, func() {}, 2)
|
||||
var planned []string
|
||||
var info string
|
||||
for _, r := range res {
|
||||
if r.Status == "planned" {
|
||||
planned = append(planned, r.Path)
|
||||
}
|
||||
if r.Type == "info" {
|
||||
info = r.Error
|
||||
}
|
||||
}
|
||||
if len(planned) != 2 || planned[0] != "a.md" || planned[1] != "b.md" {
|
||||
t.Fatalf("上限 2 且要照固定順序:%v", planned)
|
||||
}
|
||||
if !strings.Contains(info, "3") {
|
||||
t.Fatalf("要告訴使用者還剩幾筆:%q", info)
|
||||
}
|
||||
if len(m.PendingTakedowns) != 5 {
|
||||
t.Fatal("dry-run 不該動清單")
|
||||
}
|
||||
// 0=不限(移除資料夾那條路維持原行為)。
|
||||
res0, _ := drainPendingTakedowns(cfg, m, "/x", "folder_takedown", "", func() {}, true, func() {}, 0)
|
||||
if len(res0) != 5 {
|
||||
t.Fatalf("0 應該不限,實得 %d", len(res0))
|
||||
}
|
||||
}
|
||||
+32
-8
@@ -53,15 +53,21 @@ type CloudAIState struct {
|
||||
// - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」,
|
||||
// 金鑰問題由既有的連線流程去報,不混在這裡講。
|
||||
func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) {
|
||||
state, _, err := probeWorkersAIWithStatus(ctx, cypherURL, apiKey)
|
||||
return state, err
|
||||
}
|
||||
|
||||
// probeWorkersAIWithStatus=ProbeWorkersAI,多回 HTTP 狀態碼(0=沒拿到回應)給路由退避用(#121)。
|
||||
func probeWorkersAIWithStatus(ctx context.Context, cypherURL, apiKey string) (CloudAIState, int, error) {
|
||||
base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
|
||||
if base == "" {
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, 0, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workersAIExtractURL(base), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, 0, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey))
|
||||
@@ -72,7 +78,7 @@ func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState
|
||||
// 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。
|
||||
// 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」,
|
||||
// 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -81,15 +87,15 @@ func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次",
|
||||
}, nil
|
||||
}, resp.StatusCode, nil
|
||||
case resp.StatusCode < 500:
|
||||
// 200/400/401… 都代表這條 route 存在=雲端有這個功能。
|
||||
return CloudAIState{Ready: true}, nil
|
||||
return CloudAIState{Ready: true}, resp.StatusCode, nil
|
||||
default:
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode),
|
||||
}, nil
|
||||
}, resp.StatusCode, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +109,23 @@ func (c *DirectConfig) probeWorkersAI() CloudAIState {
|
||||
if note := c.unreachableNote(); note != "" {
|
||||
return CloudAIState{Ready: false, Note: note}
|
||||
}
|
||||
// #121:萃取那條路正在退避 ⇒ 探測也不打(打的是同一個端點),直接講退避的原因。
|
||||
extractURL := workersAIExtractURL(c.CypherURL)
|
||||
if note := c.routeNote(extractURL); note != "" {
|
||||
return CloudAIState{Ready: false, Note: note}
|
||||
}
|
||||
// #121:一分鐘內問過就沿用(見 cloudcheck.go)——路由退避讓一輪變短之後,
|
||||
// 這一發原本會變成每 5 秒打一次。
|
||||
if st, ok := cachedAI(c.CypherURL, c.ForceSync); ok {
|
||||
return st
|
||||
}
|
||||
gate := c.openGate(stepProbeAI)
|
||||
state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey)
|
||||
state, status, err := probeWorkersAIWithStatus(gate.ctx, c.CypherURL, c.APIKey)
|
||||
// #121:探測**只記失敗、不記成功**。空 text 的探測通了,不代表真的萃取通了——
|
||||
// 讓它歸零的話,每輪開頭一發探測就會把萃取累積的失敗清掉,退避永遠不會生效。
|
||||
if err != nil || status >= 500 || status == 429 {
|
||||
cloudRoutes.record(extractURL, directNow(), status, err, false)
|
||||
}
|
||||
gate.release()
|
||||
if err == nil {
|
||||
gate.ok()
|
||||
@@ -114,5 +135,8 @@ func (c *DirectConfig) probeWorkersAI() CloudAIState {
|
||||
// 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。
|
||||
return CloudAIState{Ready: false, Note: perr.Error()}
|
||||
}
|
||||
if err == nil {
|
||||
rememberAI(c.CypherURL, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
+16
@@ -37,6 +37,16 @@ type SyncProgress struct {
|
||||
Pending int `json:"pending"` // 還沒送,會自動接著做(含退避等待中的)
|
||||
Stuck int `json:"stuck"` // 連續失敗達上限、已暫停自動重試——**不會自己好**
|
||||
Unreadable int `json:"unreadable"` // 格式讀不了,根本沒進 manifest(由呼叫端補進來)
|
||||
// Failing=Pending 裡「最近一次已經送失敗過、正在退避等重試」的份數
|
||||
// (`inkstone/arcrun-rag#159`)。**它是 Pending 的子集合,不進不變式**——
|
||||
// 加總永遠只算 Total == Done+Pending+Stuck+Unreadable。
|
||||
//
|
||||
// 🔴 為什麼要分出來:Pending 同時裝著兩種完全不同的處境——「還沒輪到」與
|
||||
// 「一直送不上去」。混在一起時畫面只能講「同步中」,而 2026-08-28 leo 桌面上
|
||||
// geek6688-test1 的 14 份**每一份都在失敗重試**(雲端回 HTTP 500 Too many
|
||||
// subrequests,fail_count 是 7 與 3),講「同步中」等於叫他等一件正在壞掉的事。
|
||||
// 分出來,畫面才判斷得出該不該示警(見 arcrun-app/folder_badge.go)。
|
||||
Failing int `json:"failing"`
|
||||
}
|
||||
|
||||
// Add 把另一個資料夾/帳號的進度累加進來——首頁與診斷檔講的都是總量。
|
||||
@@ -47,6 +57,7 @@ func (p SyncProgress) Add(o SyncProgress) SyncProgress {
|
||||
Pending: p.Pending + o.Pending,
|
||||
Stuck: p.Stuck + o.Stuck,
|
||||
Unreadable: p.Unreadable + o.Unreadable,
|
||||
Failing: p.Failing + o.Failing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +80,11 @@ func (m *Manifest) Progress() SyncProgress {
|
||||
p.Stuck++
|
||||
default:
|
||||
p.Pending++
|
||||
if e.FailCount > 0 {
|
||||
// #159:還在排隊,但**已經失敗過**——退避等下一次重試。
|
||||
// 與 Pending 並存(子集合),不影響上面的不變式。
|
||||
p.Failing++
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -51,6 +51,11 @@ type QuotaNotice struct {
|
||||
ExitOptions string `json:"exit_options"` // 可以換一個模型,或升級 Cloudflare(每月 5 美元)
|
||||
Guarantee string `json:"guarantee"` // 不花錢也沒關係,今天/明天早上 8:00 會自動恢復
|
||||
ResumeAt string `json:"resume_at"` // RFC3339,預期恢復時間(供機器判斷冷卻是否結束)
|
||||
// arcrun-rag#197:哪一種額度。空=Workers AI(舊的三句話,status.json 向後相容);
|
||||
// d1_read/d1_write=雲端資料庫(見 cloudquota.go)。畫面依它決定標題,不猜字串。
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Headline string `json:"headline,omitempty"` // 一句話講「哪一種額度用完了」
|
||||
Usage string `json:"usage,omitempty"` // 上限多少、用了多少(查得到時;查不到也照講上限)
|
||||
}
|
||||
|
||||
// Combined 把三句話接成一句完整訊息(給只有單一 error 欄位可用的地方,如 DirectResult.Error)。
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
// route_backoff_measure_test.go — `inkstone/arcrun-rag#121`(comment 6923)的量測與驗收。
|
||||
//
|
||||
// 情境=2026-09-13 leo Mac 的實況:雲端「收卡」那條路(rag_ingest_card)持續回 HTTP 500
|
||||
// (`Node list_old_blocks failed: 缺少 credential: kbdb_internal_token`),而每一發都是
|
||||
// 雲端的全表掃。其他端點(萃取、資料夾樹、對帳)都正常。
|
||||
//
|
||||
// 量的是:假時鐘每 5 秒跑一輪(daemon 預設 poll_interval_sec),跑滿 N 分鐘,
|
||||
// 各類雲端請求一共打了幾發。**假伺服器=只數請求**,不碰任何真實例。
|
||||
//
|
||||
// 🔴 這裡的 5 秒是**下限**:真實一輪還要加上本身的耗時(leo Mac 上約 15 秒一輪),
|
||||
// 所以同樣 N 分鐘,真機的輪數比這裡少——這份數字是「最壞情況」,不是真機的精確值。
|
||||
//
|
||||
// 修前基準(commit 7a3eb2c,同一支量測、同一個情境):
|
||||
//
|
||||
// 1 分鐘( 12 輪):收卡路由 151 發
|
||||
// 10 分鐘(120 輪):收卡路由 1291 發
|
||||
// 60 分鐘(720 輪):收卡路由 7326 發,萃取 120 次(同一批檔反覆重萃)
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type routeCounts struct {
|
||||
Rounds int
|
||||
CardRoute int // rag_ingest_card 收到幾發(壞掉那段時間的每一發=雲端一次全表掃)
|
||||
CardOK map[string]bool // 雲端恢復後,哪些 path 真的送成功了
|
||||
Extract int // /portal/daemon/extract 真的萃取(燒 AI 額度)
|
||||
Probe int // /portal/daemon/extract 空探測
|
||||
Tree int // /portal/daemon/folder-tree
|
||||
Audit int // /kbdb/entries
|
||||
Other int // 含 /health(每輪固定問的版本)
|
||||
}
|
||||
|
||||
func (c routeCounts) Total() int {
|
||||
return c.CardRoute + c.Extract + c.Probe + c.Tree + c.Audit + c.Other
|
||||
}
|
||||
|
||||
// runCardRouteDownScenario 建一個「30 份新檔+10 份待修出處的舊卡」的資料夾,
|
||||
// 讓收卡路由在前 downFor 內持續 500(之後恢復),模擬 minutes 分鐘(每 pollSec 秒一輪)。
|
||||
func runCardRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration) routeCounts {
|
||||
return runRouteDownScenario(t, minutes, pollSec, downFor, "ok")
|
||||
}
|
||||
|
||||
// runRouteDownScenario:extractMode="ok"|"5xx"(萃取端點恆回 503,含空探測)|"refuse"(萃取端點連線直接被切斷=連不上)。
|
||||
func runRouteDownScenario(t *testing.T, minutes, pollSec int, downFor time.Duration, extractMode string) routeCounts {
|
||||
t.Helper()
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
// 量測要用正式值:每輪固定問雲端的兩件事(版本、雲端 AI)一分鐘只問一次(cloudcheck.go)。
|
||||
withCloudCheckInterval(t, 60*time.Second)
|
||||
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 30; i++ {
|
||||
name := fmt.Sprintf("筆記%02d", i)
|
||||
writeFile(t, root, name+".md", "# "+name+"\n\n內容 "+name, baseTime.Add(time.Duration(i)*time.Minute))
|
||||
}
|
||||
// 10 份舊形出處的卡(Arcrun#167 的就地修正會想重推它們)。
|
||||
var docs []wikiDoc
|
||||
for i := 0; i < 10; i++ {
|
||||
rel := fmt.Sprintf("舊資料/.wiki/舊檔%02d.md", i)
|
||||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(legacyCard), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs = append(docs, wikiDoc{Node: "舊資料", Path: fmt.Sprintf("舊檔%02d.pdf", i),
|
||||
Status: "extracted", Card: fmt.Sprintf("舊檔%02d", i), Cards: []string{rel}})
|
||||
}
|
||||
if err := saveWikiManifest(root, &wikiManifest{Version: 1, Docs: docs}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 9, 13, 1, 0, 0, 0, time.UTC)
|
||||
var clockNs atomic.Int64
|
||||
clockNs.Store(start.UnixNano())
|
||||
oldNow := directNow
|
||||
directNow = func() time.Time { return time.Unix(0, clockNs.Load()).UTC() }
|
||||
defer func() { directNow = oldNow }()
|
||||
|
||||
var mu sync.Mutex
|
||||
c := routeCounts{CardOK: map[string]bool{}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/rag_ingest_card/trigger"):
|
||||
c.CardRoute++
|
||||
if time.Unix(0, clockNs.Load()).Before(start.Add(downFor)) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"Node list_old_blocks failed: 缺少 credential: kbdb_internal_token"}`))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
c.CardOK[req.Path] = true
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
case strings.HasSuffix(r.URL.Path, "/portal/daemon/extract"):
|
||||
var req struct {
|
||||
PageName string `json:"page_name"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
mode := extractMode
|
||||
if mode == "5xx-10m" { // 前 10 分鐘 503,之後恢復
|
||||
mode = "ok"
|
||||
if time.Unix(0, clockNs.Load()).Before(start.Add(10 * time.Minute)) {
|
||||
mode = "5xx"
|
||||
}
|
||||
}
|
||||
switch mode {
|
||||
case "5xx":
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
c.Probe++
|
||||
} else {
|
||||
c.Extract++
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"upstream unavailable"}`))
|
||||
return
|
||||
case "refuse":
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
c.Probe++
|
||||
} else {
|
||||
c.Extract++
|
||||
}
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
_ = conn.Close() // 沒有任何回應就斷線=client 端拿到連線層錯誤
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
c.Probe++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
c.Extract++
|
||||
subject := req.PageName
|
||||
if subject == "" {
|
||||
subject = "未命名"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": cardFixture(subject, "測試")})
|
||||
case strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree"):
|
||||
c.Tree++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
case strings.HasSuffix(r.URL.Path, "/kbdb/entries"):
|
||||
c.Audit++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "total": 1})
|
||||
default:
|
||||
c.Other++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
Extractor: "workers-ai", ExtractorExplicit: true,
|
||||
MachineLabel: "量測機",
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
Accounts: []AccountConfig{{
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
WatchFolders: []string{root},
|
||||
}},
|
||||
}
|
||||
end := start.Add(time.Duration(minutes) * time.Minute)
|
||||
for now := start; now.Before(end); now = now.Add(time.Duration(pollSec) * time.Second) {
|
||||
clockNs.Store(now.UnixNano())
|
||||
RunDirectOnce(cfg, false)
|
||||
c.Rounds++
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
// TestCardRouteDown_Measure:雲端收卡路由**從頭壞到尾**,daemon 打上去的請求數。
|
||||
// 情境與修前基準(檔頭)逐字相同,數字可以直接對照。
|
||||
func TestCardRouteDown_Measure(t *testing.T) {
|
||||
// 修前:151/1291/7326。上限=門檻 4 發(第一輪內停下)+每個退避窗口到期各 1 發,再留一點餘裕。
|
||||
limits := map[int]int{1: 5, 10: 10, 60: 15}
|
||||
for _, minutes := range []int{1, 10, 60} {
|
||||
c := runCardRouteDownScenario(t, minutes, 5, 24*time.Hour)
|
||||
t.Logf("收卡路由持續 500,模擬 %2d 分鐘(%3d 輪):收卡路由 %4d 發|萃取 %3d|探測 %3d|資料夾樹 %2d|對帳 %3d|其他 %d|合計 %4d",
|
||||
minutes, c.Rounds, c.CardRoute, c.Extract, c.Probe, c.Tree, c.Audit, c.Other, c.Total())
|
||||
if c.CardRoute > limits[minutes] {
|
||||
t.Errorf("%d 分鐘內打壞掉那條路 %d 發,超過 %d——退避沒有生效", minutes, c.CardRoute, limits[minutes])
|
||||
}
|
||||
// 送不出去就不該反覆萃:30 份新檔最多各萃一次(第一份是撞牆的那一發)。
|
||||
if c.Extract > 30 {
|
||||
t.Errorf("%d 分鐘內萃取 %d 次,同一批檔被重複燒 AI 額度", minutes, c.Extract)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRouteDown_Measure:`inkstone/arcrun-rag#121` comment 6960 補的驗收——
|
||||
// 萃取端點(/portal/daemon/extract,不經 postJSON)持續 5xx/連不上,收卡路由正常。
|
||||
// 修前基準見 comment(同一支情境在 7a3eb2c 上跑)。
|
||||
func TestExtractRouteDown_Measure(t *testing.T) {
|
||||
limits := map[int]int{1: 6, 10: 12, 60: 16} // 萃取+探測合計;與收卡路由同一個量級
|
||||
for _, mode := range []string{"5xx", "refuse"} {
|
||||
for _, minutes := range []int{1, 10, 60} {
|
||||
c := runRouteDownScenario(t, minutes, 5, 0, mode)
|
||||
t.Logf("萃取端點持續 %-6s,模擬 %2d 分鐘(%3d 輪):萃取 %4d 發|探測 %4d|萃取端點合計 %4d|收卡路由 %3d|資料夾樹 %d|其他 %d",
|
||||
mode, minutes, c.Rounds, c.Extract, c.Probe, c.Extract+c.Probe, c.CardRoute, c.Tree, c.Other)
|
||||
if got := c.Extract + c.Probe; got > limits[minutes] {
|
||||
t.Errorf("%s/%d 分鐘:打萃取端點 %d 發,超過 %d——萃取那條路沒有退避", mode, minutes, got, limits[minutes])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRouteDown_RecoversByItself:萃取端點壞 10 分鐘後恢復——不重開、不按按鈕,
|
||||
// 30 份新檔都要自己萃完並送上去(退避不准變成「永久放棄」,也不准把檔累積到「連續失敗 8 次已暫停」)。
|
||||
func TestExtractRouteDown_RecoversByItself(t *testing.T) {
|
||||
c := runRouteDownScenario(t, 60, 5, 0, "5xx-10m")
|
||||
t.Logf("萃取端點壞 10 分鐘後恢復,模擬 60 分鐘:萃取 %d 發|探測 %d|送成功的 path %d 個",
|
||||
c.Extract, c.Probe, len(c.CardOK))
|
||||
var missing []string
|
||||
for i := 0; i < 30; i++ {
|
||||
if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("萃取端點恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardRouteDown_RecoversByItself:壞 10 分鐘之後雲端修好了——
|
||||
// 不重開小幫手、不按任何按鈕,所有該送的東西都要自己送上去(退避不准變成「永久放棄」)。
|
||||
func TestCardRouteDown_RecoversByItself(t *testing.T) {
|
||||
c := runCardRouteDownScenario(t, 60, 5, 10*time.Minute)
|
||||
t.Logf("壞 10 分鐘後恢復,模擬 60 分鐘:收卡路由 %d 發|送成功的 path %d 個|萃取 %d",
|
||||
c.CardRoute, len(c.CardOK), c.Extract)
|
||||
var missing []string
|
||||
for i := 0; i < 30; i++ {
|
||||
if p := fmt.Sprintf("筆記%02d.md", i); !c.CardOK[p] {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
if p := fmt.Sprintf("舊資料/舊檔%02d.pdf", i); !c.CardOK[p] {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("雲端恢復 50 分鐘後仍有 %d 份沒送上去:%v", len(missing), missing)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 退避本身的規則 ────────────────────────────────────────────────────────
|
||||
|
||||
func TestRouteBreaker_Rules(t *testing.T) {
|
||||
b := &routeBreaker{routes: map[string]*routeState{}}
|
||||
u := "https://x.workers.dev/webhooks/named/ns/rag_ingest_card/trigger"
|
||||
other := "https://x.workers.dev/webhooks/named/ns/rag_takedown_direct/trigger"
|
||||
t0 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// 2xx(即使 body 寫工作流失敗,postJSON 也只把狀態碼交進來)與一般 4xx 不開閘。
|
||||
b.record(u, t0, 200, nil, false)
|
||||
for i := 0; i < 10; i++ {
|
||||
b.record(u, t0, 400, nil, false)
|
||||
}
|
||||
if n := b.note(u, t0); n != "" {
|
||||
t.Fatalf("2xx/400 不該讓整條路退避:%q", n)
|
||||
}
|
||||
|
||||
// 有前科的內容再失敗:不算數。
|
||||
for i := 0; i < 10; i++ {
|
||||
b.record(u, t0, 500, nil, true)
|
||||
}
|
||||
if n := b.note(u, t0); n != "" {
|
||||
t.Fatalf("先前就失敗過的檔再失敗,不准把整條路關掉:%q", n)
|
||||
}
|
||||
|
||||
// 第一次送的內容連續失敗:前 3 發照常重試,第 4 發 ⇒ 停 1 分鐘;只停這一條,別條路照走。
|
||||
for i := 0; i < routeStrikesBeforeBackoff-1; i++ {
|
||||
b.record(u, t0, 500, nil, false)
|
||||
}
|
||||
if n := b.note(u, t0); n != "" {
|
||||
t.Fatalf("單次(未達門檻)的 500 下一輪要照常重試:%q", n)
|
||||
}
|
||||
b.record(u, t0, 500, nil, false)
|
||||
n := b.note(u, t0.Add(30*time.Second))
|
||||
if n == "" || !strings.Contains(n, "rag_ingest_card") {
|
||||
t.Fatalf("500 之後應該退避並講出是哪條路:%q", n)
|
||||
}
|
||||
if !explainsWhySkipped(n) {
|
||||
t.Fatalf("退避訊息要講得出原因(sync_status.go 的識別字):%q", n)
|
||||
}
|
||||
if strings.Contains(n, "500") || strings.Contains(n, "HTTP") {
|
||||
t.Fatalf("畫面上的話不准裸露狀態碼:%q", n)
|
||||
}
|
||||
if b.note(other, t0) != "" {
|
||||
t.Fatal("一條路壞了不該牽連同台知識庫的其他路")
|
||||
}
|
||||
if b.note(u, t0.Add(time.Minute)) != "" {
|
||||
t.Fatal("窗口到了就要放一發試試")
|
||||
}
|
||||
|
||||
// 連續失敗 ⇒ 窗口變長;429 與連線錯誤也算。
|
||||
b.record(u, t0.Add(time.Minute), 429, nil, false)
|
||||
if b.note(u, t0.Add(time.Minute+90*time.Second)) == "" {
|
||||
t.Fatal("窗口到期後再失敗一發,應該停 2 分鐘")
|
||||
}
|
||||
b.record(u, t0.Add(3*time.Minute), 0, errors.New("dial tcp: connection refused"), false)
|
||||
if b.note(u, t0.Add(3*time.Minute+4*time.Minute)) == "" {
|
||||
t.Fatal("再失敗一發應該停 5 分鐘")
|
||||
}
|
||||
// 一發成功 ⇒ 全部歸零:之後單次 500 不會立刻停。
|
||||
b.record(u, t0.Add(9*time.Minute), 200, nil, false)
|
||||
b.record(u, t0.Add(9*time.Minute), 500, nil, false)
|
||||
if b.note(u, t0.Add(9*time.Minute)) != "" {
|
||||
t.Fatal("成功一次後計數要歸零,重新累積到門檻前不該停")
|
||||
}
|
||||
// 上限 30 分鐘。
|
||||
for i := 0; i < 20; i++ {
|
||||
b.record(u, t0, 500, nil, false)
|
||||
}
|
||||
if b.note(u, t0.Add(30*time.Minute)) != "" {
|
||||
t.Fatal("退避上限是 30 分鐘,雲端修好後最慢半小時要自己接上")
|
||||
}
|
||||
}
|
||||
|
||||
// 「立刻同步」=使用者明確要求這一輪照打(與逐檔退避同一個語意)。
|
||||
func TestRouteBreaker_ForceSyncBypasses(t *testing.T) {
|
||||
cloudRoutes.reset()
|
||||
defer cloudRoutes.reset()
|
||||
u := "https://y.workers.dev/webhooks/named/ns/rag_ingest_card/trigger"
|
||||
for i := 0; i < routeStrikesBeforeBackoff; i++ {
|
||||
cloudRoutes.record(u, directNow(), 503, nil, false)
|
||||
}
|
||||
cfg := &DirectConfig{}
|
||||
if cfg.routeNote(u) == "" {
|
||||
t.Fatal("503 之後應該在退避中")
|
||||
}
|
||||
cfg.ForceSync = true
|
||||
if cfg.routeNote(u) != "" {
|
||||
t.Fatal("按了「立刻同步」這一輪要照打")
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// routebackoff.go — 雲端某條路持續失敗時,跨輪退避(`inkstone/arcrun-rag#121` comment 6923)。
|
||||
//
|
||||
// 病(2026-09-13 leo Mac 實測):雲端 rag_ingest_card 每一發都回 HTTP 500
|
||||
// (`list_old_blocks` 缺 credential),而那個節點是全表掃。daemon 每 5 秒一輪、
|
||||
// 每一輪都把同一面牆重撞一次:出處修正(Arcrun#167)一輪最多 20 發、資料夾索引一發、
|
||||
// 每個新檔再各一發——youlin 的免費 D1 一小時被讀掉 600 萬列、當天知識庫停擺。
|
||||
//
|
||||
// 既有的閘為什麼都沒擋住:
|
||||
// - 逐檔退避(manifest FailCount)管的是「這個檔壞了」,一批沒試過的新檔每個都會先撞一次;
|
||||
// - roundGuard(stallguard.go)只管「等到超時」,而且每輪歸零;
|
||||
// - 額度冷卻只認得 Workers AI 的額度訊息。
|
||||
//
|
||||
// ⇒ 缺的是「**這條路**壞了」這一層:同一台知識庫的同一個端點連續失敗,
|
||||
//
|
||||
// 就整條路停一段時間,時間一次比一次長;有一發成功就立刻恢復。
|
||||
//
|
||||
// 判準(什麼算「路壞了」):
|
||||
// - HTTP 5xx、429、連不上/逾時 ⇒ 算。
|
||||
// - HTTP 2xx 但 body 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容,
|
||||
// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。
|
||||
// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。
|
||||
//
|
||||
// 🔴 什麼時候判「路壞了」(而不是「那一份內容壞了」):
|
||||
// - 單看狀態碼分不出來——工作流任何節點失敗都回 500,不管是路壞還是那張卡怪。
|
||||
// - 所以要**連續 routeStrikesBeforeBackoff 發「第一次送」的請求都失敗**才停。
|
||||
// 「第一次送」=逐檔病歷上沒有失敗紀錄的檔,以及出處修正/總覽/目錄索引/下架這類系統件。
|
||||
// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管),
|
||||
// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(t217 餓死病換個形狀回來)。
|
||||
//
|
||||
// 使用者按「立刻同步」(ForceSync)=這一輪不看退避,照打(與逐檔退避同一個語意)。
|
||||
//
|
||||
// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// routeBackoffLadder=同一條路連續失敗第 N 次之後要停多久。
|
||||
// 第一格 1 分鐘:一次偶發的 500 不該讓使用者等太久;
|
||||
// 上限 30 分鐘:雲端修好之後,最慢半小時內自己接上,不必重開小幫手。
|
||||
var routeBackoffLadder = []time.Duration{
|
||||
1 * time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 30 * time.Minute,
|
||||
}
|
||||
|
||||
// routeStrikesBeforeBackoff=同一條路連續幾發「第一次送」都失敗,才判定是路壞了。
|
||||
//
|
||||
// 為什麼是 4 而不是 1:一次 500 可能只是那一份內容的問題,下一輪就該照常重試
|
||||
// (既有測試守著:下架失敗下一輪補、收回失敗下一輪補、總覽退避到期就補、
|
||||
// 單輪上限 3 份全是壞檔時健康檔下一輪要遞補得上來)。
|
||||
// 為什麼不是更多:2026-09-13 的實況,一輪開頭的出處修正+總覽+目錄索引+第一個新檔
|
||||
// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。
|
||||
const routeStrikesBeforeBackoff = 4
|
||||
|
||||
type routeState struct {
|
||||
fails int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
type routeBreaker struct {
|
||||
mu sync.Mutex
|
||||
routes map[string]*routeState
|
||||
}
|
||||
|
||||
// cloudRoutes=整個行程共用的一份(跨輪、跨帳號;key 自帶主機名,帳號之間不會互相牽連)。
|
||||
var cloudRoutes = &routeBreaker{routes: map[string]*routeState{}}
|
||||
|
||||
// routeBackoffError=「這一發根本沒打出去,因為這條路正在退避」。
|
||||
// 呼叫端用 isRouteBackoff 認它:這不是那個檔的錯,不准記進逐檔病歷。
|
||||
type routeBackoffError struct{ note string }
|
||||
|
||||
func (e *routeBackoffError) Error() string { return e.note }
|
||||
|
||||
func isRouteBackoff(err error) bool {
|
||||
_, ok := err.(*routeBackoffError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// routeKey=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。
|
||||
func routeKey(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
return u.Host + u.Path
|
||||
}
|
||||
|
||||
// routeLabel=給人看的「哪一條路」。只取工作流名,不出現網址與狀態碼。
|
||||
func routeLabel(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "雲端"
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
// /webhooks/named/<ns>/<workflow>/trigger
|
||||
if len(parts) >= 5 && parts[0] == "webhooks" && parts[len(parts)-1] == "trigger" {
|
||||
return parts[len(parts)-2]
|
||||
}
|
||||
switch u.Path {
|
||||
case "/portal/daemon/extract":
|
||||
return "雲端 AI 整理文件"
|
||||
case "/portal/daemon/folder-tree":
|
||||
return "回報資料夾結構"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// note 回「這條路現在正在退避」的白話;空=可以打。
|
||||
func (b *routeBreaker) note(raw string, now time.Time) string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
st := b.routes[routeKey(raw)]
|
||||
if st == nil || !now.Before(st.until) {
|
||||
return ""
|
||||
}
|
||||
wait := st.until.Sub(now).Round(time.Second)
|
||||
// 🔴 結尾「稍後會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go),不要改掉。
|
||||
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在失敗那一發的結果裡已經留著。
|
||||
return fmt.Sprintf("雲端「%s」這條路連續失敗 %d 次,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
|
||||
routeLabel(raw), st.fails, humanWait(wait))
|
||||
}
|
||||
|
||||
// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤;
|
||||
// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。
|
||||
func (b *routeBreaker) record(raw string, now time.Time, status int, transportErr error, retry bool) {
|
||||
failed := transportErr != nil || status >= 500 || status == 429
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
key := routeKey(raw)
|
||||
if !failed {
|
||||
if status >= 200 && status < 300 {
|
||||
delete(b.routes, key) // 通了就全部歸零
|
||||
}
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
return // 有前科的內容再失敗:它自己的病,不算數也不歸零
|
||||
}
|
||||
st := b.routes[key]
|
||||
if st == nil {
|
||||
st = &routeState{}
|
||||
b.routes[key] = st
|
||||
}
|
||||
st.fails++
|
||||
if st.fails < routeStrikesBeforeBackoff {
|
||||
return
|
||||
}
|
||||
idx := st.fails - routeStrikesBeforeBackoff
|
||||
if idx >= len(routeBackoffLadder) {
|
||||
idx = len(routeBackoffLadder) - 1
|
||||
}
|
||||
st.until = now.Add(routeBackoffLadder[idx])
|
||||
}
|
||||
|
||||
// reset 清空全部狀態(測試用)。
|
||||
func (b *routeBreaker) reset() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.routes = map[string]*routeState{}
|
||||
}
|
||||
|
||||
func humanWait(d time.Duration) string {
|
||||
if d >= time.Minute {
|
||||
return fmt.Sprintf("%d 分鐘", int((d+time.Minute-1)/time.Minute))
|
||||
}
|
||||
return fmt.Sprintf("%d 秒", int(d/time.Second))
|
||||
}
|
||||
|
||||
// routeNote=打某條路之前先問「它現在在退避嗎」。按「立刻同步」的這一輪一律照打。
|
||||
//
|
||||
// arcrun-rag#197:雲端資料庫今天的免費額度用完 ⇒ 這台知識庫的每一條路都不打,
|
||||
// **連「立刻同步」也不打**——恢復前每一發只會拿到同一個錯;而「立刻同步」那一輪
|
||||
// cloudVersionThrottled 會強制重問 /health,額度真的恢復了這裡自然就放行。
|
||||
func (c *DirectConfig) routeNote(raw string) string {
|
||||
if note := d1QuotaNote(c.CypherURL, directNow()); note != "" {
|
||||
return note
|
||||
}
|
||||
if c.ForceSync {
|
||||
return ""
|
||||
}
|
||||
return cloudRoutes.note(raw, directNow())
|
||||
}
|
||||
@@ -535,8 +535,22 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
|
||||
// 3) 先配對 renamed(design §3 順序 1):removed×added 以 content_hash 配對,
|
||||
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
|
||||
// arcrun-rag#104(comment 6309):先把「manifest 有、現況沒有」的路徑分成兩種——
|
||||
// retired = 它還在磁碟上(或不在),但**現在的策略不收它**(例:補了「logseq/ 不收」那條)
|
||||
// vanished= 策略會收它,它卻不見了(真的刪了、資料夾沒掛載)
|
||||
// 兩種都要下架,差別在下面第 6 步:只有 vanished 受大量刪除防呆管;
|
||||
// retired 是我們自己的決定,不是意外,要照下架,而且要講出為什麼(票上的紅線:排除規則要看得見)。
|
||||
retiredWhy := map[string]string{}
|
||||
for _, p := range removedPaths {
|
||||
if excluded, why := opts.Plan.ExcludesPathWhy(p, root); excluded {
|
||||
retiredWhy[p] = why
|
||||
}
|
||||
}
|
||||
removedByHash := map[string][]string{}
|
||||
for _, p := range removedPaths {
|
||||
if _, retired := retiredWhy[p]; retired {
|
||||
continue // 不再收的檔不是「搬走了」,不准跟新檔配成 renamed(那會讓殘影換個名字留在雲端)
|
||||
}
|
||||
h := orig[p].ContentHash
|
||||
removedByHash[h] = append(removedByHash[h], p)
|
||||
}
|
||||
@@ -604,9 +618,15 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
}
|
||||
|
||||
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
|
||||
var finalRemoved []string
|
||||
var finalRemoved []string // 真的不見了的(受防呆管)
|
||||
var retired []string // 策略不再收的(不受防呆管,一律下架)
|
||||
for _, p := range removedPaths {
|
||||
if !pairedOld[p] {
|
||||
if pairedOld[p] {
|
||||
continue
|
||||
}
|
||||
if _, ok := retiredWhy[p]; ok {
|
||||
retired = append(retired, p)
|
||||
} else {
|
||||
finalRemoved = append(finalRemoved, p)
|
||||
}
|
||||
}
|
||||
@@ -628,6 +648,34 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
|
||||
}
|
||||
}
|
||||
// arcrun-rag#104(comment 6309):策略不再收的檔照下架,不看防呆——那道閘擋的是意外,
|
||||
// 這是決定。實據:leo21c 的 KB 4,193 份裡 3,620 份是 `logseq/bak/` 殘影(86%),
|
||||
// 防呆若管到它們,殘影就永遠留在雲端、警告每輪都響、而且看起來像資料夾壞了。
|
||||
// 但一定要講出來(同一張票的紅線「不要讓用戶猜」):多少份、為什麼、會發生什麼。
|
||||
if len(retired) > 0 {
|
||||
reasonCount := map[string]int{}
|
||||
for _, p := range retired {
|
||||
reasonCount[retiredWhy[p]]++
|
||||
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
|
||||
}
|
||||
topReason, topN := "", 0
|
||||
for why, n := range reasonCount {
|
||||
if n > topN || (n == topN && why < topReason) {
|
||||
topReason, topN = why, n
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("有 %d 份先前收過的檔,依現在的收檔規則不再收,會從知識庫下架(%d 份的理由:%s)",
|
||||
len(retired), topN, topReason)
|
||||
if len(reasonCount) > 1 {
|
||||
msg += fmt.Sprintf(";另有 %d 種其他理由", len(reasonCount)-1)
|
||||
}
|
||||
warnings = append(warnings, Warning{
|
||||
Code: "plan_retired",
|
||||
Message: msg,
|
||||
RemovedCount: len(retired),
|
||||
ManifestCount: manifestCountBefore,
|
||||
})
|
||||
}
|
||||
|
||||
// 7) 更新 manifest(rebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
|
||||
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
|
||||
|
||||
+9
-1
@@ -108,6 +108,12 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR
|
||||
if m == nil || (!force && m.SourceOriginRepairedAt > 0) {
|
||||
return nil // 這個根已經修完,不再每輪掃
|
||||
}
|
||||
// 🔴 `inkstone/arcrun-rag#121`:收卡那條路正在退避 ⇒ 這輪整個不修。
|
||||
// 2026-09-13 實撞:這支每輪最多重推 20 份,而那條路每一發都是雲端全表掃,
|
||||
// 失敗時又沒有任何閘 ⇒ 每 5 秒 20 發,是燒光 youlin D1 的主力。
|
||||
if !dryRun && !force && cfg.routeNote(cfg.triggerURL(cfg.CardIngestWF)) != "" {
|
||||
return nil
|
||||
}
|
||||
wm := loadWikiManifest(absRoot)
|
||||
if len(wm.Docs) == 0 {
|
||||
if !dryRun {
|
||||
@@ -181,7 +187,9 @@ func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryR
|
||||
res.Err = "重推知識庫失敗(HTTP " + itoa(status) + ")"
|
||||
}
|
||||
remaining++ // 這一份沒推成功 ⇒ 本機不動(留著舊形當重試訊號)、這個根不能蓋章
|
||||
continue
|
||||
// #121:一份失敗就停手——後面那幾份打的是同一條路,繼續只是把同一面牆撞 20 次
|
||||
//(同 folderindex.go 的 t195 教訓)。沒修到的下一輪接著修。
|
||||
break
|
||||
}
|
||||
|
||||
// ② 推成功了才寫本機(文件卡+概念卡)。
|
||||
|
||||
@@ -105,6 +105,23 @@ type SyncStatus struct {
|
||||
// 與 Retiring 同族的現況快照,見 ResyncStatus 註解。
|
||||
Resync map[string]ResyncStatus `json:"resync,omitempty"`
|
||||
|
||||
// FolderProgress=**逐個看守資料夾**的同步現況(key=資料夾路徑,`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// 🔴 為什麼要有:小幫手畫面上每個資料夾都要能一眼看出「同步了沒」,而在這之前
|
||||
// 那一格唯一的資料來源是 #140 的補送說明——**它只在有補送的時候才有值**,
|
||||
// 於是畫面只好拿「有沒有那句話」當狀態。2026-08-28 leo 看到七列有六列寫「補送中」,
|
||||
// 連 pending 已經是 0 的也照樣寫(實測 youlinhsieh-test1:pending=0/repaired=2,
|
||||
// 畫面仍標補送中)——那個標籤量的根本不是同步狀態。
|
||||
//
|
||||
// 🔴 **不另算一套**:值就是 (*Manifest).Progress() 的原件,與首頁那行大數字、
|
||||
// 診斷檔用同一個函式。逐資料夾加起來會等於首頁總量,因為它們本來就是同一組
|
||||
// 數字被切開,不是兩份實作。
|
||||
//
|
||||
// 與 FolderPlans 同族的現況快照;某一輪那個根沒掃成(payload 為 nil)時**沿用
|
||||
// 上一輪**,不要讓使用者看到昨天還好好的資料夾突然沒有狀態
|
||||
// (同 MergeFolderTreeStore ② 的理由)。
|
||||
FolderProgress map[string]SyncProgress `json:"folder_progress,omitempty"`
|
||||
|
||||
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
|
||||
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
|
||||
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。
|
||||
|
||||
Reference in New Issue
Block a user