2 Commits

23 changed files with 1976 additions and 43 deletions
+25
View File
@@ -47,6 +47,31 @@
---
## 0.18.522026-09-13
- 🔴 **雲端資料庫的免費額度用完時,小幫手會直接告訴你**:以前 Cloudflare 免費帳號當天的資料庫讀取(或寫入)額度用完,小幫手只會顯示看不懂的錯誤(例如「缺少 credential」)或「連續失敗、已暫停自動重試」,看起來像東西壞了。現在首頁會出現一張卡,寫明是哪一種額度用完、免費方案的上限是多少(讀取每天 500 萬列、寫入每天 10 萬列)、台北時間幾點恢復(早上 8:00),以及恢復後會自動接著傳、你不用做任何事。
- **額度用完期間不再重打雲端**:在額度恢復之前,小幫手不會再送任何東西上雲端(連「立刻同步」也不會白打),不會把情況弄得更糟;恢復後下一次檢查就自動接上。
- 急著用的話,卡片上也會告訴你:升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限。
## 0.18.512026-09-13
- 🔴 **雲端出錯時,小幫手不會再一直重打把你的額度燒光**:以前只要你的知識庫某個功能壞掉(例如收筆記或 AI 整理文件那一步一直回錯誤),小幫手每幾秒就把同一件事重打一次,每一次都在消耗雲端的資料庫讀取或 AI 額度,免費帳號一小時就可能被用光。現在同一件事連續失敗幾次,小幫手會先停下來,隔 1 分鐘、2 分鐘、5 分鐘……最長 30 分鐘再試一次;雲端一修好就自動接上,不用重開。
- **停下來的時候會告訴你原因**:面板上會寫「雲端某個功能連續失敗,先停幾分鐘再試」,不會看起來像當掉。
- **送不上去的文件不會先白白整理一次**:雲端那一步壞著的時候,小幫手不再先花 AI 額度整理文件、再撞同一面牆。
- 按「立刻同步」一樣會馬上再試一次。
## 0.18.502026-09-07
- 🔴 **Logseq 的備份資料夾不再被當成新知識**:以前 Logseq 每次自動存檔留在 `logseq/bak/` 裡的舊版本,小幫手全都當成一份新文件收進知識庫——一頁筆記改十次就多十份殘影,問 AI 會回一堆一模一樣的舊東西。現在 `logseq/` 這整個資料夾(設定、備份、版本檔)一律不收,你的筆記本體在 pages 與 journals 裡照收不誤;面板上會寫明「這是 Logseq 自己的設定與備份資料夾」。
- **已經收進去的殘影會自動從知識庫撤掉**:更新後第一次同步,小幫手會把先前收過、現在依規則不再收的檔一批一批下架(每輪幾十份,慢慢清,不會把雲端打爆),並在面板上告訴你「有幾份因為什麼理由下架」。這種「我們自己決定不收」的情況不會再被「檔案大量消失」的保護機制擋住——那道保護只管真的不見的檔。
- **消失的資料夾,雲端那張資料夾索引卡也會一起撤**:以前只清本機的記帳,雲端會永遠留著一張指向不存在資料夾的卡。
## 0.18.492026-08-29
- **雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的**:以前小幫手回報資料夾結構時沒有附上自己是誰,所以你在雲端看到的每一個資料夾都掛在「未知來源」底下——幾年下來累積的資料夾全擠在同一個位置,看起來就像別人的東西。現在每一次回報都帶著這台電腦的名字(你在設定裡取過名就用你取的)。
- 改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去。
- ⚠️ 這一格要雲端也換新了才看得到;小幫手這邊先把名字送出去。收什麼檔、怎麼整理完全沒改。
## 0.18.482026-08-28
- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。
+9 -1
View File
@@ -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
}
+90
View File
@@ -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), "/")
}
// cloudVersionThrottledfetchCloudVersion,但同一台一分鐘內只真的問一次。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}
}
+107
View File
@@ -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
View File
@@ -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 每日免費額度用完」,回 readwrite""。
// 只認 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{} // keycloudCheckKey(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 + "(會自動恢復)。"
}
+188
View File
@@ -0,0 +1,188 @@
package collector
// cloudquota_test.go — arcrun-rag#197:雲端資料庫(D1)免費額度用完時,
// ① 認得出來 ② 整個帳號不再打雲端 ③ 用戶看得到白話 ④ 恢復後自己接上。
//
// /health 的回應原文照抄 2026-09-13 20:3x 對 youlin1.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 bindingDB)指向正確的 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:00UTC 前一天 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 應失效")
}
}
+23 -15
View File
@@ -1,10 +1,10 @@
{
"_algo": 4,
"version": "0.18.48",
"fingerprint": "a440a58a629f960c",
"version": "0.18.52",
"fingerprint": "5ab62f966033ea4b",
"files": {
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
"CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b",
"CHANGELOG.md": "718ac1789302c16de2af1595b4081a0b0b2eff88ffd007f33f78720b79521c18",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
@@ -17,8 +17,12 @@
"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",
"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": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698",
@@ -91,7 +95,7 @@
"cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c",
"cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55",
"cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494",
"cmd/arcrun-app/frontend/src/main.js": "327877a5bb79b79589e09b902e7002e33d275a413451ff90f230bb2e92418b52",
"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",
@@ -124,7 +128,7 @@
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
"direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f",
"direct.go": "3a79d03fce931115739a0113494a61331155172c7f9e5cbee126203b3e616458",
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
@@ -137,16 +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",
"folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314",
"folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089",
"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",
@@ -156,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",
@@ -172,23 +178,25 @@
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
"probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980",
"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",
+5 -1
View File
@@ -18,5 +18,9 @@
"0.18.45": "dc05513103f93306",
"0.18.46": "1325784830f592ff",
"0.18.47": "0decb0810da46a3f",
"0.18.48": "a440a58a629f960c"
"0.18.48": "a440a58a629f960c",
"0.18.49": "7cb86e646aeaa057",
"0.18.50": "b30716c3ccec9e65",
"0.18.51": "3cfcc815cf185a68",
"0.18.52": "5ab62f966033ea4b"
}
+12
View File
@@ -247,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>
+123 -10
View File
@@ -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)
}
// postJSONAspostJSON,多帶一個 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) // #1215xx429 記一次失敗,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 的,沒有什麼要記住)。
@@ -771,7 +794,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
accHost := instanceHostOf(acc.CypherURL)
// t103per-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` 那行紅字寫「目前連不上這個
@@ -940,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 = &notice
extractorOK = false
extractorError = notice.Headline + "。" + notice.Guarantee
}
accountDetails[accHost] = accSt
}
@@ -1166,19 +1200,40 @@ func saveDirectConfig(configPath string, cfg *DirectConfig) error {
// 相同 ⇒ 撤除其中一個會連坐另一個。library 是逐根導出的(libraryFor),把它一起送上去,
// 雲端才有辦法只殺對的那一份。這與 ingest 送的 library 是**同一個函式**算出來的,
// 守 2026-07-24 那條教訓:成對操作(上架/下架)要用同一把鍵。
//
// maxPerRunarcrun-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:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件——
@@ -1187,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()
@@ -1222,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
}
@@ -1299,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
@@ -1542,8 +1607,13 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// payload.AllExcludedDirsplan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries**
//(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。
// 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。
//
// StampMachine`inkstone/Arcrun#180`):蓋上「這棵樹是哪一台機器算的」。蓋在這裡
// ——build 之後、Publish 與 sync 之前——所以**本機快照與上雲酬載必然是同一份身分**。
// 卡片那條路(folderindexinventorysourcerepair)用的是同一個 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)
}
@@ -1678,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 小時**,且它排在佇列前面,
@@ -1740,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()
@@ -1774,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 張卡都送會互相蓋寫同一頁。
@@ -1826,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
}
@@ -1845,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 的牆,還拖住整個佇列)。
@@ -1896,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 模式檔在
@@ -1957,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
View File
@@ -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"
}
// extractWithWorkersAIExtractWithWorkersAI,多帶 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_urlconfig")
}
@@ -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) // #1215xx429 記失敗,2xx 歸零
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode == http.StatusNotFound {
+15 -3
View File
@@ -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#104comment 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
+41
View File
@@ -149,6 +149,41 @@ type FolderTree struct {
Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來
TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數
GeneratedAt int64 `json:"generated_at"`
// MachineMachineLabel**這棵樹是哪一台機器報上來的**`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
+173
View File
@@ -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.goinventory.gosourcerepair.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("改名把比對鍵也改掉了——那會讓雲端以為多出一台機器")
}
}
+53
View File
@@ -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 6309leo 2026-09-05:「bak 是不要的」)。
// Logseq graph 底下的 `logseq/` 裝的是它的設定(config.edn)、每次編輯自動留的備份
// `bak/`)與版本檔(`version-files/`)——**筆記本體在 `pages/``journals/`**。
// 實據(leo21c2026-09-05):`~/Documents/KB` 4,193 份文件裡 **3,620 份**是 `logseq/bak/`
// 的殘影,同一頁的每一個歷史版本都被當成一份新知識收進 `kb`;08-13 早就判過是垃圾
// (48 萬筆廢資料的三重放大之一),D82 重灌後又吃回來。
// 判準用 vault.go 既有的那一套(要有佐證才算 Logseq graph):一個碰巧叫 `logseq`
// 的普通資料夾(旁邊沒有 pagesjournalsconfig.edn)照收——漏判只是多收一個資料夾,
// 誤判是把使用者的東西弄不見。
if name == "logseq" && logseqCorroborated(filepath.Dir(absPath)) {
return true, "這是 Logseq 自己的設定與備份資料夾(bakversion-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
View File
@@ -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 ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把
// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。
+281
View File
@@ -0,0 +1,281 @@
package collector
// logseq_bak_residue_test.go — `inkstone/arcrun-rag#104` comment 6309leo 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 graphpagesjournals 是筆記本體,
// 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×2journals×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 的普通資料夾(旁邊沒有 pagesjournalsconfig.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/837.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
View File
@@ -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
}
// probeWorkersAIWithStatusProbeWorkersAI,多回 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
}
+5
View File
@@ -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_readd1_write=雲端資料庫(見 cloudquota.go)。畫面依它決定標題,不猜字串。
Kind string `json:"kind,omitempty"`
Headline string `json:"headline,omitempty"` // 一句話講「哪一種額度用完了」
Usage string `json:"usage,omitempty"` // 上限多少、用了多少(查得到時;查不到也照講上限)
}
// Combined 把三句話接成一句完整訊息(給只有單一 error 欄位可用的地方,如 DirectResult.Error)。
+364
View File
@@ -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")
}
// runRouteDownScenarioextractMode"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) {
// 修前:1511291/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("2xx400 不該讓整條路退避:%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
View File
@@ -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)管的是「這個檔壞了」,一批沒試過的新檔每個都會先撞一次;
// - roundGuardstallguard.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())
}
+50 -2
View File
@@ -535,8 +535,22 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
// arcrun-rag#104comment 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#104comment 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) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
+9 -1
View File
@@ -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
}
// ② 推成功了才寫本機(文件卡+概念卡)。