不再把開發用 template 塞進使用者資料夾;上游錯誤不再被退避訊息吞掉
## leo 三條裁決,逐條落實
① 「**別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服**」
→ manifest entry 新增 `LastError`(存**原文**),退避訊息改成
「上次失敗(第 N 次),58m 後重試|**原因:**<上游原文>」。
成功後清空,不留舊錯誤嚇人。
測試 `TestUpstreamErrorSurvivesBackoff` 從上游錯誤字串出發,
驗它活到給使用者看的那句話;反向驗證拿掉即紅。
② 「**拿來開發一般人用不到的根本別安裝**」
→ daemon 不再代裝 system-dev template(`CLAUDE.md`/`scripts/`/`system-dev/`,37 檔)。
兩層傷害:把人家資料夾弄亂 + 那些檔被當知識吃進去
⇒ 知識庫長出 `kb`/`t195-watch` 這種不是使用者內容的庫(leo 實撞)。
`template-install` 子命令保留,開發者情境不受影響。
③ 「**它也不能只看隱藏檔內,因為 template 在我所有的 repo 裡不是隱藏的**」
→ 排除規則用**路徑身分**(`TemplateOwns()`,來源是內嵌 templatefs 的實際路徑
+ `system-dev/`・`scripts/` 目錄前綴),**不是**「有沒有以點開頭」。
測試刻意把 template 檔放成**不隱藏**(就像 leo 的 repo),驗它仍被排除;反向驗證即紅。
這些檔也不計進「有 N 個檔案沒有被整理」——那欄是給使用者看他自己的檔案的。
## 順帶修 leo 截圖上的重複
「有 2 個檔案沒有被整理」底下 `scripts/sdd-active-check.sh` 出現兩次
——多帳號時同一個資料夾被掃多輪、每輪都 append。已去重。
## 殘項(誠實)
App 端失敗卡還沒接上這條線 ⇒ **畫面尚未真的顯示原因**。未打包、未送達。
This commit is contained in:
@@ -648,6 +648,18 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
|||||||
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
|
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
|
||||||
st.SkippedOtherCount = skippedOther
|
st.SkippedOtherCount = skippedOther
|
||||||
// 少量時點名(maxOtherNames 個以內)——leo 08-06 封測:只報「1 個」等於沒說。
|
// 少量時點名(maxOtherNames 個以內)——leo 08-06 封測:只報「1 個」等於沒說。
|
||||||
|
// 🔴 leo 2026-08-06 截圖:「有 2 個檔案沒有被整理」底下同一個檔名出現**兩次**。
|
||||||
|
// 真兇:多帳號時同一個資料夾會被掃很多輪,每輪都把檔名 append 進來。
|
||||||
|
// ⇒ 去重(順帶讓總數與清單一致,不然使用者會覺得我們在亂數)。
|
||||||
|
seen := map[string]bool{}
|
||||||
|
uniq := skippedOtherNames[:0]
|
||||||
|
for _, n := range skippedOtherNames {
|
||||||
|
if !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
uniq = append(uniq, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skippedOtherNames = uniq
|
||||||
sort.Strings(skippedOtherNames)
|
sort.Strings(skippedOtherNames)
|
||||||
if len(skippedOtherNames) > maxOtherNames {
|
if len(skippedOtherNames) > maxOtherNames {
|
||||||
skippedOtherNames = skippedOtherNames[:maxOtherNames]
|
skippedOtherNames = skippedOtherNames[:maxOtherNames]
|
||||||
@@ -744,13 +756,24 @@ func retrySkipReason(m *Manifest, path string, now int64) string {
|
|||||||
return "暫時跳過"
|
return "暫時跳過"
|
||||||
}
|
}
|
||||||
if e.FailCount >= MaxFailBeforeSkip {
|
if e.FailCount >= MaxFailBeforeSkip {
|
||||||
return fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
|
msg := fmt.Sprintf("連續失敗 %d 次,已暫停自動重試(改檔或按「立刻同步」會再試)", e.FailCount)
|
||||||
|
if e.LastError != "" {
|
||||||
|
msg += "|原因:" + e.LastError
|
||||||
|
}
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
wait := e.NextRetry - now
|
wait := e.NextRetry - now
|
||||||
if wait < 0 {
|
if wait < 0 {
|
||||||
wait = 0
|
wait = 0
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String())
|
msg := fmt.Sprintf("上次失敗(第 %d 次),%s 後重試", e.FailCount, (time.Duration(wait) * time.Second).String())
|
||||||
|
// 🔴 leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服」。
|
||||||
|
// 只講「幾分鐘後重試」等於把上游的錯(Cloudflare 額度用完、檔案本身沒文字)
|
||||||
|
// 藏起來 ⇒ 使用者只看得到我們在失敗。真因要一路帶到畫面上。
|
||||||
|
if e.LastError != "" {
|
||||||
|
msg += "|原因:" + e.LastError
|
||||||
|
}
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountsConnected 回答「這份設定連得上知識庫嗎」——**根層有 或 任一帳號有**。
|
// accountsConnected 回答「這份設定連得上知識庫嗎」——**根層有 或 任一帳號有**。
|
||||||
@@ -822,7 +845,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
|||||||
content, rerr := os.ReadFile(full)
|
content, rerr := os.ReadFile(full)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
|
res.Status, res.Error = "failed", "讀檔失敗:"+rerr.Error()
|
||||||
m.MarkFailed(ev.Path, now) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
|
m.MarkFailed(ev.Path, now, res.Error) // t195:讀不到的檔也退避(權限/被鎖/壞掉的外接碟)
|
||||||
results = append(results, res)
|
results = append(results, res)
|
||||||
exit = 1
|
exit = 1
|
||||||
continue
|
continue
|
||||||
@@ -858,7 +881,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
|||||||
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
|
res.Status, res.Error = "failed", "本地萃取失敗:"+xerr.Error()
|
||||||
// t195:萃取階段失敗同樣要記退避。**這條路徑比上傳更早**,
|
// t195:萃取階段失敗同樣要記退避。**這條路徑比上傳更早**,
|
||||||
// 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。
|
// 漏記的話(連不上知識庫、金鑰壞、模型錯)照樣每輪重撞。
|
||||||
m.MarkFailed(ev.Path, now)
|
m.MarkFailed(ev.Path, now, res.Error)
|
||||||
results = append(results, res)
|
results = append(results, res)
|
||||||
exit = 1
|
exit = 1
|
||||||
continue
|
continue
|
||||||
@@ -894,7 +917,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool) ([]DirectRes
|
|||||||
} else {
|
} else {
|
||||||
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
|
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
|
||||||
//(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。
|
//(實撞:1387 輪 × 11 小時全在撞同一面 401 的牆,還拖住整個佇列)。
|
||||||
m.MarkFailed(ev.Path, now)
|
m.MarkFailed(ev.Path, now, res.Error)
|
||||||
exit = 1
|
exit = 1
|
||||||
}
|
}
|
||||||
results = append(results, res)
|
results = append(results, res)
|
||||||
@@ -1027,20 +1050,19 @@ func runDirect(args []string) int {
|
|||||||
return exit
|
return exit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 四步定稿第 1 步:daemon 代裝 template——常駐看守前確保每根都鋪好(冪等,不覆寫既有檔)。
|
// 🔴 2026-08-06 leo 拍板:**「拿來開發一般人用不到的根本別安裝」**
|
||||||
// dry-run/--once 測試情境不代裝(不留副作用),由 template-install 子命令顯式做。
|
//
|
||||||
if !*once && !*dryRun {
|
// 原本 daemon 會把 system-dev template(`CLAUDE.md`/`scripts/`/`system-dev/`
|
||||||
for _, root := range cfg.Folders() {
|
// 共 37 檔)代裝進**使用者的文件資料夾**。那是給開發者寫 SDD/wiki 用的東西,
|
||||||
if TemplateInstalled(root) {
|
// RAG 的一般使用者完全用不到,而且兩層傷害:
|
||||||
continue
|
// ① 把人家的資料夾弄亂(leo:「他原本的資料夾就不會看起來亂掉」)
|
||||||
}
|
// ② 那些檔案會被當成知識吃進去 ⇒ 知識庫長出 `kb`/`t195-watch` 這種
|
||||||
if res, ierr := InstallTemplate(root); ierr != nil {
|
// 不是使用者內容的庫(leo 實撞,見庫目錄管理截圖)
|
||||||
fmt.Fprintf(os.Stderr, "template 代裝失敗(%s):%v\n", root, ierr)
|
// ⇒ **看守資料夾一律不代裝**。要裝的人自己跑 `collector template-install`
|
||||||
} else {
|
// (子命令仍在,開發者情境不受影響)。
|
||||||
fmt.Fprintf(os.Stderr, "template v%s 已鋪進 %s(新 %d 檔)\n", res.Version, root, len(res.Installed))
|
//
|
||||||
}
|
// 另外還要把「已經被鋪進去的」擋在知識之外——那不是靠隱藏檔判斷,
|
||||||
}
|
// 因為 leo 自己的 repo 裡 template 本來就不是隱藏的。判準是**路徑身分**,見 scan.go。
|
||||||
}
|
|
||||||
|
|
||||||
if *once {
|
if *once {
|
||||||
return runOne()
|
return runOne()
|
||||||
|
|||||||
+14
-1
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ type ManifestEntry struct {
|
|||||||
FailCount int `json:"fail_count,omitempty"` // 連續失敗次數(成功即歸零)
|
FailCount int `json:"fail_count,omitempty"` // 連續失敗次數(成功即歸零)
|
||||||
LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒
|
LastFailAt int64 `json:"last_fail_at,omitempty"` // 最後一次失敗的 unix 秒
|
||||||
NextRetry int64 `json:"next_retry,omitempty"` // 早於這個時間不重試(0=可立即重試)
|
NextRetry int64 `json:"next_retry,omitempty"` // 早於這個時間不重試(0=可立即重試)
|
||||||
|
// LastError=最後一次失敗的**真正原因**(原文,不改寫)。
|
||||||
|
//
|
||||||
|
// 🔴 leo 2026-08-06 立的原則:「**別人的錯誤一律要顯示給用戶看,
|
||||||
|
// 不然就會變成我的錯誤,導致客服**」。
|
||||||
|
// 先前退避一開始,畫面就只剩「上次失敗(第 4 次),58m 後重試」,
|
||||||
|
// 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失**
|
||||||
|
// ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。
|
// retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。
|
||||||
@@ -133,6 +142,7 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
|
|||||||
e.ExtractedBy = extractor
|
e.ExtractedBy = extractor
|
||||||
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
|
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
|
||||||
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
|
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
|
||||||
|
e.LastError = ""
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +151,16 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
|
|||||||
// 為什麼要記在 manifest 而不是記憶體:collector 每輪是獨立 process
|
// 為什麼要記在 manifest 而不是記憶體:collector 每輪是獨立 process
|
||||||
// (`direct --once` 由看守器反覆拉起),記憶體狀態一輪就沒了——
|
// (`direct --once` 由看守器反覆拉起),記憶體狀態一輪就沒了——
|
||||||
// 這正是原本「1387 輪重試同一個檔」的原因:每輪都以為自己是第一次。
|
// 這正是原本「1387 輪重試同一個檔」的原因:每輪都以為自己是第一次。
|
||||||
func (m *Manifest) MarkFailed(path string, at int64) bool {
|
func (m *Manifest) MarkFailed(path string, at int64, reason string) bool {
|
||||||
e, ok := m.Entries[path]
|
e, ok := m.Entries[path]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
e.FailCount++
|
e.FailCount++
|
||||||
e.LastFailAt = at
|
e.LastFailAt = at
|
||||||
|
if strings.TrimSpace(reason) != "" {
|
||||||
|
e.LastError = reason // 存真因;退避訊息由呼叫端另外組,不覆蓋這裡
|
||||||
|
}
|
||||||
idx := e.FailCount - 1
|
idx := e.FailCount - 1
|
||||||
if idx >= len(retryBackoff) {
|
if idx >= len(retryBackoff) {
|
||||||
idx = len(retryBackoff) - 1
|
idx = len(retryBackoff) - 1
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestShouldRetry_NeverFailed(t *testing.T) {
|
|||||||
// 核心止血:失敗後在退避窗口內不再重試。
|
// 核心止血:失敗後在退避窗口內不再重試。
|
||||||
func TestShouldRetry_BackoffWindow(t *testing.T) {
|
func TestShouldRetry_BackoffWindow(t *testing.T) {
|
||||||
m := newRetryTestManifest("a.pdf")
|
m := newRetryTestManifest("a.pdf")
|
||||||
m.MarkFailed("a.pdf", 1000) // 第 1 次失敗 → 退避 60s
|
m.MarkFailed("a.pdf", 1000, "測試用失敗原因") // 第 1 次失敗 → 退避 60s
|
||||||
|
|
||||||
if m.ShouldRetry("a.pdf", 1030, false) {
|
if m.ShouldRetry("a.pdf", 1030, false) {
|
||||||
t.Fatal("退避窗口內(+30s)不該重試——這正是 1387 輪的病根")
|
t.Fatal("退避窗口內(+30s)不該重試——這正是 1387 輪的病根")
|
||||||
@@ -46,7 +46,7 @@ func TestMarkFailed_ExponentialBackoff(t *testing.T) {
|
|||||||
want := []int64{60, 300, 900, 3600, 21600, 21600} // 1m,5m,15m,1h,6h,之後維持 6h
|
want := []int64{60, 300, 900, 3600, 21600, 21600} // 1m,5m,15m,1h,6h,之後維持 6h
|
||||||
at := int64(1000)
|
at := int64(1000)
|
||||||
for i, w := range want {
|
for i, w := range want {
|
||||||
m.MarkFailed("a.pdf", at)
|
m.MarkFailed("a.pdf", at, "測試用失敗原因")
|
||||||
got := m.Entries["a.pdf"].NextRetry - at
|
got := m.Entries["a.pdf"].NextRetry - at
|
||||||
if got != w {
|
if got != w {
|
||||||
t.Fatalf("第 %d 次失敗:退避 %ds,預期 %ds", i+1, got, w)
|
t.Fatalf("第 %d 次失敗:退避 %ds,預期 %ds", i+1, got, w)
|
||||||
@@ -59,7 +59,7 @@ func TestShouldRetry_MaxFailStops(t *testing.T) {
|
|||||||
m := newRetryTestManifest("a.pdf")
|
m := newRetryTestManifest("a.pdf")
|
||||||
at := int64(1000)
|
at := int64(1000)
|
||||||
for i := 0; i < MaxFailBeforeSkip; i++ {
|
for i := 0; i < MaxFailBeforeSkip; i++ {
|
||||||
m.MarkFailed("a.pdf", at)
|
m.MarkFailed("a.pdf", at, "測試用失敗原因")
|
||||||
}
|
}
|
||||||
// 就算等再久也不自動重試
|
// 就算等再久也不自動重試
|
||||||
if m.ShouldRetry("a.pdf", at+999999, false) {
|
if m.ShouldRetry("a.pdf", at+999999, false) {
|
||||||
@@ -74,8 +74,8 @@ func TestShouldRetry_MaxFailStops(t *testing.T) {
|
|||||||
// 成功後要清掉失敗狀態,否則下次再壞會從高階退避起跳(等太久)。
|
// 成功後要清掉失敗狀態,否則下次再壞會從高階退避起跳(等太久)。
|
||||||
func TestMarkIngested_ResetsFailState(t *testing.T) {
|
func TestMarkIngested_ResetsFailState(t *testing.T) {
|
||||||
m := newRetryTestManifest("a.pdf")
|
m := newRetryTestManifest("a.pdf")
|
||||||
m.MarkFailed("a.pdf", 1000)
|
m.MarkFailed("a.pdf", 1000, "測試用失敗原因")
|
||||||
m.MarkFailed("a.pdf", 1100)
|
m.MarkFailed("a.pdf", 1100, "測試用失敗原因")
|
||||||
if m.Entries["a.pdf"].FailCount != 2 {
|
if m.Entries["a.pdf"].FailCount != 2 {
|
||||||
t.Fatal("失敗次數應累計")
|
t.Fatal("失敗次數應累計")
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func TestMarkIngested_ResetsFailState(t *testing.T) {
|
|||||||
func TestShouldRetry_OtherFilesUnaffected(t *testing.T) {
|
func TestShouldRetry_OtherFilesUnaffected(t *testing.T) {
|
||||||
m := newRetryTestManifest("bad.pdf")
|
m := newRetryTestManifest("bad.pdf")
|
||||||
m.Entries["good.md"] = &ManifestEntry{ContentHash: "h2", Size: 1, Mtime: 1}
|
m.Entries["good.md"] = &ManifestEntry{ContentHash: "h2", Size: 1, Mtime: 1}
|
||||||
m.MarkFailed("bad.pdf", 1000)
|
m.MarkFailed("bad.pdf", 1000, "測試用失敗原因")
|
||||||
|
|
||||||
if m.ShouldRetry("bad.pdf", 1010, false) {
|
if m.ShouldRetry("bad.pdf", 1010, false) {
|
||||||
t.Fatal("壞檔應在退避中")
|
t.Fatal("壞檔應在退避中")
|
||||||
|
|||||||
@@ -186,6 +186,14 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
|||||||
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
|
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 🔴 2026-08-06 leo:template 的東西不是使用者的知識,一律不收。
|
||||||
|
// **用路徑身分認,不用「有沒有以點開頭」認**——leo 自己的 repo 裡
|
||||||
|
// template 本來就不是隱藏的,靠隱藏判斷會漏掉一大半。
|
||||||
|
// 也不計進「有 N 個檔案沒有被整理」——那是給使用者看他自己的檔案的,
|
||||||
|
// 我們自己鋪的東西不該佔用他的注意力。
|
||||||
|
if rel, rerr := filepath.Rel(root, p); rerr == nil && TemplateOwns(filepath.ToSlash(rel)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
if !allowedExt[ext] {
|
if !allowedExt[ext] {
|
||||||
// G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
|
// G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// templateFS 是 system-dev-template 的 vendored 快照(版本見 templatefs/system-dev/VERSION)。
|
// templateFS 是 system-dev-template 的 vendored 快照(版本見 templatefs/system-dev/VERSION)。
|
||||||
@@ -43,6 +44,49 @@ func TemplateVersion() string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// templateOwnedPaths 回傳「這份 template 擁有的相對路徑」集合(斜線分隔)。
|
||||||
|
//
|
||||||
|
// 🔴 leo 2026-08-06:「**它也不能只看隱藏檔內,因為 template 在我所有的 repo 裡不是隱藏的**」
|
||||||
|
//
|
||||||
|
// ⇒ 判準必須是**路徑身分**(這些路徑本來就是 template 的),不是「有沒有以點開頭」。
|
||||||
|
// 用途:掃描時把它們排除在知識之外——不管它是 daemon 舊版鋪的、
|
||||||
|
// 還是使用者自己的開發 repo 本來就有的,那都不是他想搜尋的內容。
|
||||||
|
func templateOwnedPaths() map[string]bool {
|
||||||
|
owned := map[string]bool{}
|
||||||
|
_ = fs.WalkDir(templateFS, templateFSRoot, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rel, rerr := filepath.Rel(templateFSRoot, p); rerr == nil {
|
||||||
|
owned[filepath.ToSlash(rel)] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return owned
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateOwns 回答「這個相對路徑是不是 template 的東西」。
|
||||||
|
// 也認**目錄前綴**:template 的 `system-dev/` 底下使用者後來自己加的檔
|
||||||
|
// (例如他寫的 wiki)同樣是開發用的,不該進知識庫。
|
||||||
|
func TemplateOwns(rel string) bool {
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if templateOwnedCache[rel] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, prefix := range templateOwnedDirs {
|
||||||
|
if strings.HasPrefix(rel, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
templateOwnedCache = templateOwnedPaths()
|
||||||
|
// 這些目錄整棵都是開發用的(template 鋪的、或使用者自己長出來的都一樣)。
|
||||||
|
templateOwnedDirs = []string{"system-dev/", "scripts/"}
|
||||||
|
)
|
||||||
|
|
||||||
// InstallTemplate 把內嵌 template 鋪進 root。冪等:既有檔案跳過不覆寫。
|
// InstallTemplate 把內嵌 template 鋪進 root。冪等:既有檔案跳過不覆寫。
|
||||||
func InstallTemplate(root string) (*TemplateInstallResult, error) {
|
func InstallTemplate(root string) (*TemplateInstallResult, error) {
|
||||||
res := &TemplateInstallResult{Version: TemplateVersion()}
|
res := &TemplateInstallResult{Version: TemplateVersion()}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTemplateFilesAreNotKnowledge 釘住 leo 2026-08-06 的兩條裁決:
|
||||||
|
//
|
||||||
|
// ①「拿來開發一般人用不到的**根本別安裝**」
|
||||||
|
// ②「它也不能只看隱藏檔內,因為 **template 在我所有的 repo 裡不是隱藏的**」
|
||||||
|
//
|
||||||
|
// 事故:daemon 把 system-dev template(CLAUDE.md/scripts//system-dev/,37 檔)
|
||||||
|
// 代裝進使用者的文件資料夾,然後又把它們當知識吃進去
|
||||||
|
// ⇒ 知識庫長出 `kb`/`t195-watch` 這種不是使用者內容的庫(leo 實撞)。
|
||||||
|
//
|
||||||
|
// 這支測試刻意把 template 檔**放成不隱藏**(就像 leo 的 repo),驗它仍被排除。
|
||||||
|
func TestTemplateFilesAreNotKnowledge(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
write := func(rel, body string) {
|
||||||
|
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// template 的東西(**沒有隱藏**)
|
||||||
|
write("CLAUDE.md", "開發用設定")
|
||||||
|
write("scripts/sdd-active-check.sh", "#!/bin/sh")
|
||||||
|
write("system-dev/wiki/status.md", "開發用 wiki")
|
||||||
|
// 使用者真正的內容
|
||||||
|
write("我的筆記.md", "這是我要搜尋的東西")
|
||||||
|
|
||||||
|
m := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||||
|
res, err := Scan(root, m, ScanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("掃描失敗:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, e := range res.Events {
|
||||||
|
got[e.Path] = true
|
||||||
|
}
|
||||||
|
if !got["我的筆記.md"] {
|
||||||
|
t.Error("使用者自己的檔案不見了——排除規則太寬")
|
||||||
|
}
|
||||||
|
for _, dev := range []string{"CLAUDE.md", "scripts/sdd-active-check.sh", "system-dev/wiki/status.md"} {
|
||||||
|
if got[dev] {
|
||||||
|
t.Errorf("%s 是 template 的開發用檔,不該被當成使用者知識", dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 也不該計進「有 N 個檔案沒有被整理」——那欄是給使用者看他自己的檔案的
|
||||||
|
if res.SkippedOther > 0 {
|
||||||
|
t.Errorf("template 檔不該計進『沒被整理』(會佔用使用者的注意力),實得 %d:%v",
|
||||||
|
res.SkippedOther, res.SkippedOtherNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUpstreamErrorSurvivesBackoff 釘住 leo 2026-08-06 立的原則:
|
||||||
|
//
|
||||||
|
// 「**別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤,導致客服**」
|
||||||
|
//
|
||||||
|
// 事故現場:Cloudflare 回「當日免費額度用完」、某 PDF「沒有可抽取的文字」,
|
||||||
|
// 但檔案一進退避,畫面就只剩「上次失敗(第 4 次),58m 後重試」
|
||||||
|
// ⇒ 上游的錯被我們吞掉,使用者只看得到「Arcrun 在失敗」。
|
||||||
|
//
|
||||||
|
// 這支測試從**上游錯誤字串**出發,驗它一路活到給使用者看的那句話裡。
|
||||||
|
func TestUpstreamErrorSurvivesBackoff(t *testing.T) {
|
||||||
|
const upstream = "雲端萃取失敗(HTTP 502):Workers AI 執行失敗:4006: you have used up your daily free allocation of 10,000 neurons"
|
||||||
|
|
||||||
|
m := &Manifest{Entries: map[string]*ManifestEntry{"報告.pdf": {}}}
|
||||||
|
if !m.MarkFailed("報告.pdf", 1000, upstream) {
|
||||||
|
t.Fatal("MarkFailed 應該成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 原因要存得住(退避跨輪次,不能只活在當次記憶體裡)
|
||||||
|
if got := m.Entries["報告.pdf"].LastError; got != upstream {
|
||||||
|
t.Fatalf("上游原因沒存下來,實得 %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 使用者看到的那句話裡要有它
|
||||||
|
reason := retrySkipReason(m, "報告.pdf", 1010)
|
||||||
|
if !strings.Contains(reason, "neurons") {
|
||||||
|
t.Fatalf("退避訊息吞掉了上游的錯 ⇒ 使用者會以為是我們壞掉。實得:%s", reason)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "後重試") {
|
||||||
|
t.Errorf("重試排程也要講(兩件事都要說),實得:%s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 成功之後要清乾淨,不能一直掛著舊錯誤嚇人
|
||||||
|
m.MarkIngestedBy("報告.pdf", "hash", 2000, "workers-ai")
|
||||||
|
if m.Entries["報告.pdf"].LastError != "" {
|
||||||
|
t.Error("成功後舊的失敗原因應該清掉")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user