Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cc8788e9d | |||
| e8800d18ef | |||
| 80877af60c | |||
| ef5fe1df6f | |||
| 3d1cb83110 | |||
| 6e80296362 | |||
| 58d47d9080 | |||
| e3f1d0177d | |||
| 4d966b7922 | |||
| a1786135d5 | |||
| d028a66625 |
@@ -47,6 +47,93 @@
|
||||
|
||||
---
|
||||
|
||||
## 0.18.48(2026-08-28)
|
||||
|
||||
- 🔴 **資料夾那一列不再是一整排字**:以前每個資料夾後面掛著「補送中」「資料夾結構」「移除」三個詞,底下再壓一句長說明,七個資料夾就是七段文字。現在那一列只剩三個圖示,同步好了就是一個打勾,滑過去才會告訴你細節。
|
||||
- **打勾是真的**:以前只要沒有錯誤訊息就顯示「自動同步中」,連空資料夾、連小幫手還沒回報的資料夾都一樣。現在那個記號直接對應「這個資料夾裡幾份進去了/總共幾份」,數字對不上就不會給你打勾。
|
||||
- **一次連線失敗不再讓知識庫版本消失**:以前只要有一輪沒問到,畫面就說「目前連不上這個知識庫,查不到版本」——即使它下一秒就通了。現在問不到只影響「有沒有新版可更新」,你原本的版本號還在。
|
||||
|
||||
|
||||
## 0.18.47(2026-08-28)
|
||||
|
||||
- 這一版你不會看到任何不一樣:收檔、整理、同步、畫面全部跟上一版相同,不必特地更新。改的是我們自己出貨前的自我檢查。以前每次重新編譯程式,就會多出兩顆二十幾 MB 的編譯結果混進原始碼裡,害那道檢查誤判成「程式又被改過了」而擋下出貨——實際上一行程式都沒動。現在編譯出來的東西不再混進去,那道檢查量到的就真的只有程式本身。
|
||||
|
||||
## 0.18.46(2026-08-28)
|
||||
|
||||
- 修好上一版帶進來的問題:雲端只是回得慢,卻被講成「沒有回應」,還害後面的檔被跳過。上一版我替每一種請求設了時間上限,其中「送出一份筆記」給了 60 秒——但實際量出來它本來就要 33 到 57 秒。結果尾巴被剪掉,兩份檔判成失敗,接著整個帳號被判定沒回應,**後面 9 份檔一份都沒送**。這一版把上限依實測值放寬(送知識的請求 5 分鐘、只登記結構的 3 分鐘),並且只有在**連續**等不到回覆時才會暫停該帳號——中間只要成功過一次就重新計算。
|
||||
- 訊息也改了:只要這一輪它成功回應過,就不會再說「沒有回應」,而是說「回得太慢」。以前那句話會讓你去查網路、去重裝,全都白費。
|
||||
|
||||
## 0.18.45(2026-08-28)
|
||||
|
||||
- 資料夾結構現在幾秒內就會更新,不用等整輪同步跑完。以前這棵樹是**排在所有雲端工作的最後**才寫下來的,而一輪同步實測要十幾分鐘到好幾小時(每一次跟雲端說話就要 24〜33 秒,檔案越多輪越久)——所以你看到的是「小幫手開著、沒有錯誤訊息、資料夾結構永遠停在上一版」。那不是卡住,是這一輪還沒輪到寫它。現在它一掃完就先寫,該送上雲端的照舊。
|
||||
- 這一版沒有改收什麼檔、也沒有改整理的方式。只改了「畫面上的東西什麼時候更新」。
|
||||
|
||||
## 0.18.44(2026-08-28)
|
||||
|
||||
- 一個帳號沒有回應,不會再讓整台機器的同步停下來。以前只要有一發請求卡住,後面所有知識庫帳號、所有資料夾就一起停在那裡,畫面上什麼都不動也沒有錯誤訊息(實測卡了 39 分鐘)。現在等不到回覆的那個帳號這一輪先跳過,其他資料夾照常跑完。
|
||||
- 等待不再是靜默的。哪個知識庫、正在做哪件事、已經等了多久,會寫在畫面與紀錄裡。
|
||||
- 資料夾結構每一輪都會重新整理。以前雲端一沒回應,你看到的資料夾樹就永遠停在上一次。
|
||||
- 被跳過的檔案不算失敗。雲端沒有回應是知識庫那邊的狀況,不會被記成這個檔的失敗次數,也不會因此被停掉自動重試。
|
||||
|
||||
## 0.18.43(2026-08-28)
|
||||
|
||||
- 🔴 **小幫手不再把自己整理出來的筆記當成新資料再整理一次**:以前它整理完一份文件、把筆記寫進資料夾之後,下一輪掃描又會把那些筆記當成「新來的檔案」再整理一遍,整理出來的東西又變成下一輪的材料——一輪接一輪停不下來。你會看到資料夾裡冒出越來越多名字奇怪的檔(像是同一個詞被括號包起來重複一次),每一輪都白白用掉一次當天的整理額度,知識庫裡也被塞進一堆「筆記的筆記」。現在它認得出哪些檔是自己寫的,一律跳過。
|
||||
- **已經被繁殖出來的那些檔要自己刪**:這次的修正只保證不再繼續長,**不會回頭清掉先前多出來的檔**——那些檔跟你自己的東西混在同一個資料夾裡,我們寧可不替你決定刪哪一個。名字裡有重複括號的(例如「換柱(換柱)」)就是這次多出來的,可以直接刪掉。
|
||||
- 🔴 **裝過開發範本的資料夾,現在分得出「空白樣板」與「你真的寫過的東西」**:以前只要路徑在 `system-dev/` 底下就整批不收,於是同一個資料夾裡**你自己的筆記與現成的知識卡也一起被丟掉**(實測:一個桌面資料夾底下有 9 張整理好的卡,一張都沒上去)。現在改成逐個檔比對——**內容和安裝時鋪下去的原版一模一樣**(你還沒動過)才不收;你寫過一個字、或那個檔根本不是範本鋪的,就是你的知識,照收。
|
||||
- **這個分辨不看資料夾叫什麼名字**:換個名字裝、裝在別的位置,答案都一樣;反過來,一個剛好也叫 `system-dev` 但裡面是你自己東西的資料夾,不會再被誤殺。
|
||||
- 🔴 **資料夾樹現在真的是「所有的資料夾」**:以前有幾種資料夾我們決定不收,而「不收」的做法是**連走都不走進去**——結果是那個資料夾底下有哪幾層、哪些子資料夾,你在小幫手與知識庫網頁上**一個都看不到**,樹到那一節就斷了。(實測:桌面上一個資料夾實際有 12 層資料夾,畫面上只有 3 個。)現在每一層都會列出來,跟你在檔案總管裡看到的一樣。
|
||||
- **不收的那一層,照樣告訴你「底下有幾個檔、我收了幾個、為什麼沒收」**:以前這種資料夾要嘛不存在,要嘛顯示一個我們自己編的 `0 / 0`。現在數字是真的(例如「0 / 9」),旁邊寫著原因(例如「這是開發範本鋪下來的檔,不是你寫的東西」)——你先看得到那裡有東西,才輪得到決定要不要收它。
|
||||
- **收檔範圍一個字都沒有放寬**:這一版只改「看得到什麼」,沒有改「收什麼」。原本不收的檔案現在仍然不收。
|
||||
- 🔴 **把整理好的卡片放在資料夾裡,不會再被小幫手自己攪亂**:如果你把整理過的知識卡放在 `system-dev/wiki/cards/` 這種位置,小幫手替它們整理出來的新卡片會**被舊版的「卡片歸位」動作硬拖出來、改成別的檔名**——接著它連自己一秒前寫的卡都找不到,那批文件於是一份都上不去;被拖出來的卡又被當成你的新文件再整理一次,檔案愈長愈多(`某某(某某)`、`某某(某某)(某某)`…)。(實測:一個資料夾裡因此多出 70 個沒人要的檔案,9 份現成的卡一份也沒進到雲端。)現在那批卡片放在哪裡就留在哪裡。
|
||||
|
||||
## 0.18.42(2026-08-27)
|
||||
|
||||
- 🔴 **問 AI「這份資料的原文在哪」,現在答得出你走得到的位置**:以前它會回一個像 `../某某檔.pdf` 的東西——那是程式自己擺放檔案的相對位置,你拿著它打不開任何檔案;原稿如果放在子資料夾裡,連是哪一層都看不出來。現在每張卡的「出處」寫的是**哪一台電腦 › 哪一個知識庫 › 庫內哪一個路徑**(例如 `youlinhsieh@Leo-MBA › youlinhsieh-test1 › 會議紀錄/2026/某某.md`),照著三格走就找得到那個檔。
|
||||
- **以前已經整理過的筆記也會一起換掉,不用重新整理一次**:小幫手會把本機既有的卡片改成新寫法並重新送上去一次,**不會再花一次 AI 額度、內容其餘一個字都不動**;每個資料夾只做一次,做完就不再重複。
|
||||
- **刻意不寫完整的電腦路徑**:卡片會被別台電腦上的 AI 讀到,而完整路徑只在原本那台成立——寫了反而給你一個看起來精確、實際上打不開的答案。
|
||||
|
||||
## 0.18.41(2026-08-27)
|
||||
|
||||
- 🔴 **不叫「docs」的文件夾,現在也認得出來**:以前只有名字剛好叫 `docs`/`doc`/`documentation` 的資料夾會被讀,一個放滿 PDF、Word、筆記的資料夾只要名字不一樣(例如一個舊專案的存檔夾),就整個被當成程式碼跳過,畫面上也不會說為什麼。現在只要一個資料夾裡完全沒有程式碼、有看得懂的文件,不管叫什麼名字都會被收進來——你不用先學會「哪個按鈕可以救回一個資料夾」,大部分情況它一開始就是對的。
|
||||
|
||||
## 0.18.40(2026-08-27)
|
||||
|
||||
- 🔴 **卡住的檔案不再擋住後面所有人**:以前只要有幾個檔一直收不上去(格式讀不了、雲端當天額度滿了…),它們就會**每一輪都繼續佔著最前面的位子**,即使那一輪根本沒去試它們。結果是排在後面、本來好好的檔案永遠輪不到——你等再久、跑再多輪都一樣,數字只會往上長不會往下掉。現在退避中的檔案會讓開,後面的檔案遞補得上來,佇列真的會往前走。
|
||||
|
||||
## 0.18.39(2026-08-27)
|
||||
|
||||
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
|
||||
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
|
||||
|
||||
## 0.18.38(2026-08-26)
|
||||
|
||||
- 🔴 **每一層子資料夾都看得到,不是只有第一層**:以前你只看得到自己選的那個資料夾本身,裡面到底有哪些子資料夾、哪幾層、哪些東西真的上去了,完全看不到。現在**桌面小幫手與知識庫網頁兩邊都攤得開**:小幫手裡點進那個知識庫,資料夾右邊多一顆「資料夾結構」,按下去就是一棵像檔案總管那樣、一列一列往下縮排的樹,點資料夾展開、再點收合,也可以一鍵全部展開/全部收合。
|
||||
- **小幫手看得到,不必等雲端、也不必連線**:這棵樹是你這台電腦自己算出來的。就算網路斷了、或今天的 AI 額度用完了,它照樣打得開——那正是你最想知道「到底同步到哪了」的時候。
|
||||
- **每一層都告訴你「幾份、上去了幾份」**:每個資料夾旁邊會標「已同步 / 總共」。兩個數字不一樣也會告訴你差在哪裡——還讀不了的格式、不在收檔範圍的檔案,還是還在處理中。
|
||||
- **整個沒走進去的資料夾,不會假裝成 0 / 0**:被跳過的資料夾會直接寫「整個資料夾未收」加上原因,而不是給你一個我們自己編的數字。
|
||||
|
||||
## 0.18.37(2026-08-26)
|
||||
|
||||
- 🔴 **雲端被清空過的檔案,小幫手現在會自己發現並補送**:以前小幫手送成功一次就會在本機蓋一個「已送」的章,而那個章**永遠不會過期**。萬一雲端被重裝或清空,那批檔案就**再也不會被送上去**,而且沒有任何地方會跟你說——你只會發現「檔案明明在資料夾裡,AI 卻查不到」,而且查不出為什麼。現在它會跟雲端對一次帳,發現那邊沒有就重新送。
|
||||
- **重跑第二次不會又全部送一遍**:對完帳之後它分得出「雲端真的沒有」與「我上次送過了」,不會把你的 AI 額度燒掉。
|
||||
- **補送的時候看得到**:不會靜悄悄跑完。
|
||||
|
||||
## 0.18.36(2026-08-24)
|
||||
|
||||
- 🔴 **移除資料夾時,可以順便把我們放進去的隱藏檔案一起清掉**:小幫手整理你的資料夾時,會在**每一層**放一個隱藏的整理稿目錄(`.wiki`/`.arcrun-rag`)。以前你把資料夾從清單移除之後,這些東西**全部留在原地**——隱藏的、散在幾十層裡,你自己幾乎刪不乾淨。現在移除的對話框多了一個勾選框「順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉」,勾起來會**先把確切要刪哪些列給你看**,你看過再按確定。
|
||||
- **只刪我們自己建的,你的檔案一個都不會碰**:每一筆都要有我們自己寫下的依據(我們放的說明檔、我們的整理帳本、我們的檔名前綴)才會刪;只要有一個東西認不出來,那整個目錄就原封不動留著,並且在清單上告訴你留了什麼、為什麼留。
|
||||
- **這幾種情況我們刻意不刪**:你那個資料夾裡如果還有別的資料夾也在同步清單裡,它底下的東西不碰;以前從你資料夾搬進暫存區的檔案不碰(那些是你的);捷徑(symlink)不碰。
|
||||
- 不勾這個框的話,行為跟以前一模一樣:硬碟上一個檔都不會少。
|
||||
|
||||
## 0.18.35(2026-08-24)
|
||||
|
||||
- 🔴 **桌面小幫手多了「App 界面」:一打開就是九宮格,點一下就用得到**:以前小幫手只做一件事——把資料夾整理進知識庫;要用知識庫上的 App(例如寫一則筆記),得另外開瀏覽器登入雲端。現在小幫手最上面多了一頁「App 界面」,裡面是**你連的那個知識庫實際裝了的 App**,一個 icon 一個 App,點下去就在小幫手裡打開,不用再開瀏覽器。
|
||||
- 這批 App **跟你在知識庫網頁上看到的是同一批**:清單直接跟知識庫要,桌面上不另存一份,所以你在任何一台電腦看到的都一樣;在雲端裝了新 App,回到這裡按「重新整理」就會出現。
|
||||
- 沒有自己畫面的 App 也點得開:會列出它能做的事,一條一顆「現在執行」。
|
||||
- 連了不只一個知識庫的話,「App 界面」上方可以切換要看哪一個。
|
||||
- 還沒連上知識庫、或這個知識庫一個 App 都沒裝,畫面會直接說是哪一種情況,不會只給你一片空白。
|
||||
- **原本的功能一項都沒少**:同步狀態與「立刻同步」搬到了每一頁都看得到的最上方,首頁、各知識庫、AI 設定、版本與更新都在側邊欄原來的位置。
|
||||
|
||||
## 0.18.34(2026-08-20)
|
||||
|
||||
- 🔴 **雲端現在看得到你資料夾的完整層次,不再只有第一層**:以前不管你的資料夾疊了幾層,雲端只認得最外面那一層——`專案/教育部標案/會議紀錄` 這種放在深處的資料夾,在知識庫裡完全不存在,只會以檔案路徑的一段文字出現。現在**每一層資料夾都有自己的一格**,而且**每一格都連著它的上一層**,所以你可以像在檔案總管裡一樣,從最外層一路點進最深的那個資料夾。每一格還會告訴你這一層直接放了幾份文件、連同底下的子資料夾一共幾份。
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// cardloop_test.go — 「卡片自我繁殖」迴歸網(inkstone/Arcrun#134 → comment 5025)。
|
||||
//
|
||||
// 🔴 2026-08-28 在 leo 的 `~/Desktop/youlinhsieh-test1` 實測到的現象:
|
||||
// 一份 20 KB 的劇本原稿,`.wiki/` 只落了 4 張卡;而 `system-dev/wiki/cards/` 底下
|
||||
// 卻在 20 分鐘內從 9 個檔長到 55 個,出現 `arcrun-換柱(arcrun-換柱).md`
|
||||
// 這種第三代檔名。那不是萃取品質問題,是**產物被自己的整理程序拖回原稿區**:
|
||||
//
|
||||
// ① 萃取寫 `<node>/.wiki/換柱.md`(隱藏目錄,Scan 整棵跳過 ⇒ 不會被當原稿)
|
||||
// ② MigrateCardNames 每輪自動跑,遞迴掃 `system-dev/wiki/cards/`——
|
||||
// **它沒有跳過隱藏目錄**,於是把 `cards/.wiki/換柱.md`
|
||||
// 「歸位」成 `cards/arcrun-換柱.md`
|
||||
// ③ 那個位置 Scan 看得見 ⇒ 下一輪當成新原稿再萃一次 ⇒ 回到 ①
|
||||
//
|
||||
// 每一圈都燒一次 Workers AI 額度、往雲端推一批「卡片的卡片」,而且不會停。
|
||||
//
|
||||
// 四支測試各釘住迴圈的一個環節(tidy 自動路/tidy 手動路/收檔判準/claude 路),
|
||||
// 缺一個就會復發。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMigrateCardNamesLeavesWikiDirAlone:`.wiki/` 是卡片的家,不是「位置不對的舊卡」。
|
||||
// MigrateCardNames 一個檔都不准動它——動了就等於把產物送回原稿區(迴圈的第②環)。
|
||||
func TestMigrateCardNamesLeavesWikiDirAlone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// 卡片產物區底下的 `.wiki/`:萃取正常的落點。
|
||||
wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir)
|
||||
if err := os.MkdirAll(wikiDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := filepath.Join(wikiDir, "換柱.md")
|
||||
if err := os.WriteFile(card, []byte("# 換柱\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mig := MigrateCardNames(root)
|
||||
|
||||
if _, err := os.Stat(card); err != nil {
|
||||
t.Fatalf("`.wiki/` 裡的卡被搬走了(MigrateCardNames 回報 Moved=%d)——"+
|
||||
"它下一輪就會被當成新原稿再萃一次,迴圈就是這樣長出來的", mig.Moved)
|
||||
}
|
||||
dragged := filepath.Join(root, filepath.FromSlash(cardsRelDir), MarkName("換柱.md"))
|
||||
if _, err := os.Stat(dragged); err == nil {
|
||||
t.Fatalf("卡片被拖回原稿區:%s", dragged)
|
||||
}
|
||||
if mig.Moved != 0 {
|
||||
t.Fatalf("Moved = %d,期望 0(`.wiki/` 不該有任何東西被搬)", mig.Moved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyLeavesWikiDirAlone:手動 `collector tidy --apply` 走的是另一條路,同樣不准碰。
|
||||
func TestTidyLeavesWikiDirAlone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir)
|
||||
if err := os.MkdirAll(wikiDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := filepath.Join(wikiDir, "官架子.md")
|
||||
if err := os.WriteFile(card, []byte("# 官架子\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rep, err := Tidy(root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(card); err != nil {
|
||||
t.Fatalf("tidy --apply 搬走了 `.wiki/` 裡的卡;報告列了 %d 項", len(rep.Items))
|
||||
}
|
||||
for _, it := range rep.Items {
|
||||
if it.Kind == TidyKindCard && filepath.Base(filepath.Dir(it.Rel)) == wikiRelDir {
|
||||
t.Fatalf("tidy 把 `.wiki/` 裡的卡列成待整理項:%s → %s", it.Rel, it.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMachineOwnedCardsAreNotIngestedAsSource:curated-wiki 模式收的是「他自己寫的 wiki」,
|
||||
// 但 `system-dev/wiki/cards/arcrun-*.md` 是 **daemon 上一版親手產的卡**
|
||||
// (檔名帶 MachineMark 就是它的身分證)。把自己的產物當原稿再萃一次
|
||||
// =迴圈的第③環,也是這次「卡片的卡片」的第一代來源。
|
||||
func TestMachineOwnedCardsAreNotIngestedAsSource(t *testing.T) {
|
||||
plan := IngestPlan{Mode: IngestCuratedWiki, WikiRelDir: "system-dev/wiki"}
|
||||
|
||||
userCard := "system-dev/wiki/cards/我自己寫的.md"
|
||||
if !plan.KeepsFile(userCard) {
|
||||
t.Fatalf("使用者自己寫的卡被排除了:%s(curated-wiki 要收的就是這個)", userCard)
|
||||
}
|
||||
|
||||
machineCard := "system-dev/wiki/cards/" + MarkName("換柱.md")
|
||||
if plan.KeepsFile(machineCard) {
|
||||
t.Fatalf("daemon 自己產的卡被當成原稿收進去:%s——"+
|
||||
"它會被再萃一次,產出「卡片的卡片」", machineCard)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCardsIgnoresWikiDir:claude 路的同一個洞。
|
||||
// snapshotCards → diffCards → enforceCardMarks 會把「這一輪新出現的卡」歸位到
|
||||
// cardRelFor(搬出 `.wiki/`、加前綴)。`.wiki/` 不進快照,就沒有東西可以被搬。
|
||||
func TestSnapshotCardsIgnoresWikiDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wikiDir := filepath.Join(root, filepath.FromSlash(cardsRelDir), wikiRelDir)
|
||||
if err := os.MkdirAll(wikiDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshotCards(root)
|
||||
if err := os.WriteFile(filepath.Join(wikiDir, "Pixel手機.md"), []byte("# Pixel手機\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed := diffCards(before, snapshotCards(root))
|
||||
if len(changed) != 0 {
|
||||
t.Fatalf("`.wiki/` 裡的新卡進了快照差集:%v——enforceCardMarks 會把它搬出隱藏目錄", changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// cardloop_vs_existing_wiki_test.go — `#134`(別把自己的卡當原稿)與 `#180`(現成的卡要收)
|
||||
// 在同一個路徑上要求相反答案,這一支把**分界線本身**釘住。
|
||||
//
|
||||
// 🔴 這不是普通的迴歸網,是一條**有爭議的分界**:兩張票的驗收條件互斥——
|
||||
//
|
||||
// #134 cardloop_test.go `system-dev/wiki/cards/arcrun-換柱.md` **不准收**
|
||||
// #180 template_not_knowledge_test.go `system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md` **必須收**
|
||||
//
|
||||
// 同一個目錄、同一個前綴 ⇒ **靠名字分不開**。現行分界是「**模式**」:
|
||||
// curated-wiki(接一個開發 repo,`cardsRelDir` 與他的 wiki 結構性重疊)擋;
|
||||
// 其餘(一般資料夾/筆記庫)收。理由與出處寫在 `ingestplan.go` 的 KeepsFile。
|
||||
//
|
||||
// ⚠️ 這條分界是收工方的假設,**leo 還沒裁**。要改分界,連這支一起改,
|
||||
// 並在 `inkstone/Arcrun#180` 留言說明改成什麼、為什麼——不要只改 code。
|
||||
package collector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKeepsFile_機器產的卡在哪一種模式下才擋(t *testing.T) {
|
||||
const machineCard = "system-dev/wiki/cards/" + MachineMark + "火星座標_短片劇本_v1.md"
|
||||
const userCard = "system-dev/wiki/cards/我自己寫的.md"
|
||||
const liveProduct = "system-dev/wiki/cards/.wiki/換柱.md"
|
||||
|
||||
// ① 接開發 repo(curated-wiki):舊版 daemon 寫在他 wiki 裡的卡不當原稿(#134)
|
||||
repo := IngestPlan{Mode: IngestCuratedWiki, WikiRelDir: "system-dev/wiki"}
|
||||
if repo.KeepsFile(machineCard) {
|
||||
t.Errorf("curated-wiki 仍該擋 %s(#134 第③環)", machineCard)
|
||||
}
|
||||
if !repo.KeepsFile(userCard) {
|
||||
t.Errorf("使用者自己寫的卡被擋了:%s", userCard)
|
||||
}
|
||||
|
||||
// ② 一般資料夾/筆記庫:leo 的 youlinhsieh-test1 就是這一種,那 9 張卡要收(#180)
|
||||
for _, m := range []IngestMode{IngestAll, ""} {
|
||||
plain := IngestPlan{Mode: m}
|
||||
if !plain.KeepsFile(machineCard) {
|
||||
t.Errorf("mode=%q 沒收 %s——#180 驗收第 3 條(那 9 張卡的內容要進得了雲端)就不成立",
|
||||
string(m), machineCard)
|
||||
}
|
||||
}
|
||||
|
||||
// ②之二 `arcrun-` 是人也會用的命名習慣:**卡片產物區以外的檔一律照收**。
|
||||
// 實測 leo 的 youlinhsieh-test1 根層就有三個他自己寫的(其中一個檔名裡寫著「請勿刪」),
|
||||
// 只看前綴的話它們會被排進「下架」——不是不收而已,是把雲端已經有的撤掉。
|
||||
// (curated-wiki 只讀那份 wiki,所以根層的檔本來就不收——那是模式的範圍,
|
||||
// 不是前綴造成的;這裡分別用兩種模式各檢一格,才不會把兩件事混在一起。)
|
||||
plainAll := IngestPlan{Mode: IngestAll}
|
||||
for _, rel := range []string{
|
||||
MachineMark + "複驗用-請勿刪-20260827c.md",
|
||||
MachineMark + "1457驗收-20260827.md",
|
||||
} {
|
||||
if !plainAll.KeepsFile(rel) {
|
||||
t.Errorf("mode=all 把使用者自己命名的根層檔擋掉了:%s", rel)
|
||||
}
|
||||
}
|
||||
if !repo.KeepsFile("system-dev/wiki/" + MachineMark + "我放在wiki根的筆記.md") {
|
||||
t.Error("curated-wiki 把卡片產物區**以外**、帶前綴的檔擋掉了")
|
||||
}
|
||||
|
||||
// ③ 不管哪一種模式,**這一版正在維護的產物**(住在 `.wiki/`)永遠不是原稿。
|
||||
// 這一條才是關掉「卡片的卡片」迴圈的那道閘,前綴不是。
|
||||
for _, m := range []IngestMode{IngestCuratedWiki, IngestDocsOnly, IngestAll, ""} {
|
||||
p := IngestPlan{Mode: m, WikiRelDir: "system-dev/wiki"}
|
||||
if p.KeepsFile(liveProduct) {
|
||||
t.Errorf("mode=%q 把 `.wiki/` 裡的產物當原稿收了:%s", string(m), liveProduct)
|
||||
}
|
||||
}
|
||||
}
|
||||
+543
@@ -0,0 +1,543 @@
|
||||
// cleanup.go — 斷連時把 daemon 自己寫進使用者資料夾的東西收回來
|
||||
// (arcrun-rag#138,leo 2026-08-24:「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
|
||||
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」)。
|
||||
//
|
||||
// 🔴 這是本 repo 唯一一支會**刪使用者資料夾裡的檔**的程式碼。tidy.go 的紅線是
|
||||
// 「一行 os.Remove 都沒有」——那條紅線在這裡不成立(使用者要的就是刪掉),
|
||||
// 所以改用另一組更嚴的規矩:
|
||||
//
|
||||
// ① **只刪帳面上認得出來的東西。** 不靠「路徑看起來像我們的」「副檔名是 .md」
|
||||
// 這類推測——每一筆都要說得出一句**我們自己寫下的證據**(見 CleanupItem.Evidence)。
|
||||
// ② **一個認不出來的鄰居,就讓整個目錄留下。** `.wiki/` 裡只要有一個檔不在帳本上、
|
||||
// 也沒有標記,就從「刪整個目錄」降級成「只刪認得出來的那幾個檔」,目錄本身留著。
|
||||
// ⇒ 誤留一點殘渣是可接受的;誤刪使用者的知識不是。
|
||||
// ③ **先看清單再動手。** PlanCleanup 只讀不寫,ApplyCleanup 動手前**重算一次**計畫
|
||||
// (不吃呼叫端傳回來的舊清單)——畫面上看到的是快照,刪的當下才是真相。
|
||||
//
|
||||
// 認人的三種證據(全部是 daemon 自己寫下去的字,不是猜的):
|
||||
//
|
||||
// E1 `.arcrun-rag/` 工作區:目錄裡的 `.gitignore` 內容**逐字**等於 workspaceIgnoreBody
|
||||
// (repoguard.go 寫的那份,自己寫著「整個資料夾可以安全刪除」)。
|
||||
// E2 `.wiki/` 卡片目錄:目錄裡的 `.gitignore` 內容逐字等於 wikiIgnoreBody
|
||||
// (wikishape.go 寫的,自己寫著「不是你的檔案」),或目錄裡有解析得開的
|
||||
// `manifest.json`(我們的 wiki 帳本)。
|
||||
// E3 `system-dev/wiki/cards/` 底下帶 MachineMark(`arcrun-`)前綴的檔
|
||||
// ——machinemark.go 的規約:daemon 在監看根底下產生的每個檔名都帶這個前綴。
|
||||
//
|
||||
// 🔴 三個**刻意不刪**的東西(每一個都是實查出來的,不是保守而已):
|
||||
//
|
||||
// ① `.arcrun-rag/legacy-template/`:那是 tidy.go 的**收容處**,裡面裝的是從使用者
|
||||
// 資料夾**搬進來的檔**。tidy 自己說得很清楚:「TemplateOwns 是路徑身分判準,
|
||||
// 分不出『daemon 鋪的』還是『repo 本來就有的』」⇒ 那些檔的所有權我們判不了。
|
||||
// 而 `.arcrun-rag/.gitignore` 上「整個資料夾可以安全刪除」那句話是 #105 寫的,
|
||||
// 當時工作區裡還沒有收容處。**不把那句話套到收容處上**(#138 紅線第三條問的正是這題)。
|
||||
// ② 另一個**還在監看清單裡**的資料夾底下的東西:實務上真的會巢狀
|
||||
// (2026-08-24 現場:`pms` 與 `pms/pms_v1_legacy` 同時在看守)。移除外層時把內層的
|
||||
// 工作區刪掉=把一個還在跑的同步弄壞。
|
||||
// ③ 任何 symlink:不跟隨、不刪。刪一條指出去的連結會刪到監看根以外的東西。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 產物分類(給畫面分組用,也是 Apply 的處理方式)。
|
||||
const (
|
||||
CleanupKindWorkspace = "workspace" // `.arcrun-rag/` 工作區(整個目錄)
|
||||
CleanupKindWorkFile = "work-file" // 工作區裡的單一項目(收容處存在時的降級路徑)
|
||||
CleanupKindWikiDir = "wiki-dir" // `.wiki/` 卡片目錄(整個目錄)
|
||||
CleanupKindWikiFile = "wiki-file" // `.wiki/` 裡的單一產物(有陌生鄰居時的降級路徑)
|
||||
CleanupKindMarked = "marked-card" // 帶 arcrun- 標記的卡片(非隱藏的卡片產物區)
|
||||
)
|
||||
|
||||
// CleanupItem=一筆「打算刪掉」的東西。Evidence 是白話的認人依據,會直接顯示給使用者看
|
||||
// ——沒有 Evidence 的東西不准進這個清單。
|
||||
type CleanupItem struct {
|
||||
Rel string `json:"rel"` // 相對監看根,斜線分隔
|
||||
IsDir bool `json:"is_dir"`
|
||||
Kind string `json:"kind"`
|
||||
Evidence string `json:"evidence"`
|
||||
Files int `json:"files"` // 這一筆底下有幾個檔(目錄才 > 1)
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// CleanupKeep=一筆「認得出來但刻意不刪」或「認不出來所以不敢刪」的東西。
|
||||
// 這份清單和 Remove 一樣要給使用者看——沉默地留下殘渣,跟沉默地刪掉一樣糟。
|
||||
type CleanupKeep struct {
|
||||
Rel string `json:"rel"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// CleanupPlan=一次清理的完整帳目。PlanCleanup 只讀不寫,這份東西就是「動手前的清單」。
|
||||
type CleanupPlan struct {
|
||||
Root string `json:"root"`
|
||||
Remove []CleanupItem `json:"remove"`
|
||||
Keep []CleanupKeep `json:"keep"`
|
||||
Files int `json:"files"` // Remove 涵蓋的檔案總數
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// CleanupFailure=刪不掉的那一筆(權限、檔案正被開著…)。
|
||||
type CleanupFailure struct {
|
||||
Rel string `json:"rel"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// CleanupResult=實際刪掉了什麼。
|
||||
type CleanupResult struct {
|
||||
Removed []string `json:"removed"`
|
||||
Failed []CleanupFailure `json:"failed"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
var cleanupBakRe = regexp.MustCompile(`^(.*)\.bak-\d+$`)
|
||||
|
||||
// WorkspaceIgnoreBodyForTest/WikiIgnoreBodyForTest 讓**別的 package 的迴歸網**
|
||||
// (cmd/arcrun-app)造得出「與 daemon 真正寫下去一字不差」的宣告檔。
|
||||
//
|
||||
// 為什麼不讓測試自己抄一份字串:抄的那份會漂。認人的判準是「逐字相等」,
|
||||
// 判準與樣本一旦不同源,這兩份 .gitignore 改一個字就會有一整層測試靜默失效。
|
||||
// 名字帶 ForTest 是說給讀 code 的人聽的——**production 路徑不准依賴它們**。
|
||||
func WorkspaceIgnoreBodyForTest() string { return workspaceIgnoreBody }
|
||||
func WikiIgnoreBodyForTest() string { return wikiIgnoreBody }
|
||||
|
||||
// PlanCleanup 列出「把 root 從監看清單移除時,可以連帶收掉的 daemon 產物」。
|
||||
//
|
||||
// **這支函式不寫任何檔案。** otherRoots=其他仍在監看清單裡的資料夾(絕對路徑);
|
||||
// 落在它們底下的東西一律不碰(見檔頭不刪清單②)。
|
||||
func PlanCleanup(root string, otherRoots []string) (*CleanupPlan, error) {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
absRoot = filepath.Clean(absRoot)
|
||||
if st, serr := os.Lstat(absRoot); serr != nil {
|
||||
return nil, serr
|
||||
} else if !st.IsDir() {
|
||||
return nil, &fs.PathError{Op: "cleanup", Path: absRoot, Err: fs.ErrInvalid}
|
||||
}
|
||||
|
||||
// 只留「真的在 absRoot 底下」的其他根——不在底下的本來就走不到,留著只會拖慢比對。
|
||||
var others []string
|
||||
for _, o := range otherRoots {
|
||||
a, oerr := filepath.Abs(o)
|
||||
if oerr != nil {
|
||||
continue
|
||||
}
|
||||
a = filepath.Clean(a)
|
||||
if a != absRoot && pathWithin(absRoot, a) {
|
||||
others = append(others, a)
|
||||
}
|
||||
}
|
||||
|
||||
p := &cleanupPlanner{
|
||||
root: absRoot,
|
||||
others: others,
|
||||
cards: collectWikiCardIndex(absRoot, others),
|
||||
plan: &CleanupPlan{Root: absRoot},
|
||||
}
|
||||
p.walk(absRoot)
|
||||
sort.Slice(p.plan.Remove, func(i, j int) bool { return p.plan.Remove[i].Rel < p.plan.Remove[j].Rel })
|
||||
sort.Slice(p.plan.Keep, func(i, j int) bool { return p.plan.Keep[i].Rel < p.plan.Keep[j].Rel })
|
||||
return p.plan, nil
|
||||
}
|
||||
|
||||
// ApplyCleanup 真的動手刪。
|
||||
//
|
||||
// 🔴 它**重算一次計畫**,不接受呼叫端傳進來的舊清單——使用者看清單、按確定之間可能過了
|
||||
// 幾秒或幾分鐘,那段時間裡他可能剛好把一個自己的檔存進 `.wiki/`。畫面上那份是快照,
|
||||
// 刪的當下重算的這份才是判準。回傳的 plan 就是實際執行的那一份(呼叫端要顯示就用它)。
|
||||
func ApplyCleanup(root string, otherRoots []string) (*CleanupPlan, *CleanupResult, error) {
|
||||
plan, err := PlanCleanup(root, otherRoots)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
res := &CleanupResult{}
|
||||
// 由深到淺刪:先刪目錄裡的東西,再刪目錄自己(降級路徑會同時出現父目錄底下的多筆)。
|
||||
items := append([]CleanupItem(nil), plan.Remove...)
|
||||
sort.Slice(items, func(i, j int) bool { return len(items[i].Rel) > len(items[j].Rel) })
|
||||
for _, it := range items {
|
||||
abs := filepath.Join(plan.Root, filepath.FromSlash(it.Rel))
|
||||
// 最後一道機械閘:不管計畫怎麼算的,目標一定要在監看根底下,而且不是 symlink。
|
||||
if !pathWithin(plan.Root, abs) {
|
||||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "路徑不在這個資料夾底下,沒有刪"})
|
||||
continue
|
||||
}
|
||||
st, serr := os.Lstat(abs)
|
||||
if serr != nil {
|
||||
if os.IsNotExist(serr) {
|
||||
continue // 已經不在了(重複按、或上一輪刪過)=不是失敗
|
||||
}
|
||||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: serr.Error()})
|
||||
continue
|
||||
}
|
||||
if st.Mode()&os.ModeSymlink != 0 {
|
||||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: "這是一條捷徑(symlink),沒有刪"})
|
||||
continue
|
||||
}
|
||||
var derr error
|
||||
if it.IsDir {
|
||||
derr = os.RemoveAll(abs)
|
||||
} else {
|
||||
derr = os.Remove(abs)
|
||||
}
|
||||
if derr != nil {
|
||||
res.Failed = append(res.Failed, CleanupFailure{Rel: it.Rel, Error: derr.Error()})
|
||||
continue
|
||||
}
|
||||
res.Removed = append(res.Removed, it.Rel)
|
||||
res.Files += it.Files
|
||||
res.Bytes += it.Bytes
|
||||
}
|
||||
sort.Strings(res.Removed)
|
||||
return plan, res, nil
|
||||
}
|
||||
|
||||
// ── 以下是實作細節 ────────────────────────────────────────────────
|
||||
|
||||
type cleanupPlanner struct {
|
||||
root string
|
||||
others []string
|
||||
cards map[string]bool // 帳本上每張卡的絕對路徑
|
||||
plan *CleanupPlan
|
||||
}
|
||||
|
||||
func (p *cleanupPlanner) rel(abs string) string {
|
||||
r, err := filepath.Rel(p.root, abs)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(abs)
|
||||
}
|
||||
return filepath.ToSlash(r)
|
||||
}
|
||||
|
||||
func (p *cleanupPlanner) remove(abs, kind, evidence string, isDir bool) {
|
||||
files, bytes := countTree(abs)
|
||||
p.plan.Remove = append(p.plan.Remove, CleanupItem{
|
||||
Rel: p.rel(abs), IsDir: isDir, Kind: kind, Evidence: evidence, Files: files, Bytes: bytes,
|
||||
})
|
||||
p.plan.Files += files
|
||||
p.plan.Bytes += bytes
|
||||
}
|
||||
|
||||
func (p *cleanupPlanner) keep(abs, reason string) {
|
||||
p.plan.Keep = append(p.plan.Keep, CleanupKeep{Rel: p.rel(abs), Reason: reason})
|
||||
}
|
||||
|
||||
// walk 走整棵樹。刻意**不用 filepath.WalkDir**:我們要在「進不進去某個目錄」這件事上
|
||||
// 完全自己說了算(其他隱藏目錄整棵跳過、別的監看根整棵跳過、symlink 不跟隨)。
|
||||
func (p *cleanupPlanner) walk(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
abs := filepath.Join(dir, e.Name())
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
continue // 捷徑一律不碰(可能指到監看根以外)
|
||||
}
|
||||
if !e.IsDir() {
|
||||
continue // 目錄以外的檔,只有在下面那幾個已認定的目錄裡才會被處理
|
||||
}
|
||||
if p.underOtherRoot(abs) {
|
||||
p.keep(abs, "這個資料夾也還在同步清單裡,它底下的東西留給它自己管")
|
||||
continue
|
||||
}
|
||||
switch e.Name() {
|
||||
case workspaceRelDir:
|
||||
p.planWorkspace(abs)
|
||||
continue
|
||||
case wikiRelDir:
|
||||
p.planWikiDir(abs)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(e.Name(), ".") {
|
||||
continue // 其他隱藏目錄整棵跳過——daemon 的 Scan 也不進去,那裡不會有我們的東西
|
||||
}
|
||||
if p.rel(abs) == cardsRelDir {
|
||||
p.planMarkedCards(abs)
|
||||
continue
|
||||
}
|
||||
p.walk(abs)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *cleanupPlanner) underOtherRoot(abs string) bool {
|
||||
for _, o := range p.others {
|
||||
if abs == o || pathWithin(o, abs) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planWorkspace 處理 `.arcrun-rag/`。
|
||||
func (p *cleanupPlanner) planWorkspace(abs string) {
|
||||
if !fileHasExactBody(filepath.Join(abs, ".gitignore"), workspaceIgnoreBody) {
|
||||
p.keep(abs, "這個 .arcrun-rag 目錄裡沒有我們寫的 .gitignore 宣告,認不出是不是我們建的,所以不動它")
|
||||
return
|
||||
}
|
||||
const ev = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「整個資料夾可以安全刪除」)"
|
||||
|
||||
quarantine := filepath.Join(abs, "legacy-template")
|
||||
if st, err := os.Lstat(quarantine); err == nil && st.IsDir() && !isEmptyDir(quarantine) {
|
||||
// 收容處裡是「從你的資料夾搬進來的檔」⇒ 整個目錄不能一次刪,改成逐項刪、留下收容處。
|
||||
p.keep(quarantine, "這裡面是以前從你的資料夾搬進來的檔案(不是我們產生的),所有權判不了,一律留著讓你自己處理")
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == "legacy-template" {
|
||||
continue
|
||||
}
|
||||
child := filepath.Join(abs, e.Name())
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
p.keep(child, "這是一條捷徑(symlink),不碰")
|
||||
continue
|
||||
}
|
||||
p.remove(child, CleanupKindWorkFile, ev, e.IsDir())
|
||||
}
|
||||
return
|
||||
}
|
||||
p.remove(abs, CleanupKindWorkspace, ev, true)
|
||||
}
|
||||
|
||||
// planWikiDir 處理一個 `.wiki/`。
|
||||
//
|
||||
// 兩步:① 這個目錄是不是我們的(要有我們寫下的宣告)② 裡面**每一個**東西是不是都認得。
|
||||
// 第二步是這支函式的重點——認得全部才刪整個目錄,有一個認不得就降級成逐檔刪。
|
||||
func (p *cleanupPlanner) planWikiDir(abs string) {
|
||||
evidence := ""
|
||||
if fileHasExactBody(filepath.Join(abs, ".gitignore"), wikiIgnoreBody) {
|
||||
evidence = "目錄裡的 .gitignore 逐字等於我們寫的那份(自己寫著「這個資料夾是 Arcrun RAG 產生的 wiki,不是你的檔案」)"
|
||||
} else if isOurWikiManifest(filepath.Join(abs, "manifest.json")) {
|
||||
evidence = "目錄裡有我們寫的 manifest.json(wiki 帳本,記著每張卡對應哪份原稿)"
|
||||
} else {
|
||||
p.keep(abs, "這個 .wiki 目錄裡既沒有我們寫的 .gitignore、也沒有我們的 manifest.json,認不出是不是我們建的,所以不動它")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
p.keep(abs, "讀不到這個目錄的內容("+err.Error()+"),所以不動它")
|
||||
return
|
||||
}
|
||||
type known struct {
|
||||
name string
|
||||
isDir bool
|
||||
}
|
||||
var ours []known
|
||||
var strangers []string
|
||||
accounted := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if p.wikiEntryIsOurs(abs, e) {
|
||||
accounted[e.Name()] = true
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if accounted[e.Name()] {
|
||||
ours = append(ours, known{e.Name(), e.IsDir()})
|
||||
continue
|
||||
}
|
||||
// `.bak-<unixnano>` 是 safeWriteCard 的備份:本體認得,它就認得。
|
||||
if m := cleanupBakRe.FindStringSubmatch(e.Name()); m != nil && accounted[m[1]] {
|
||||
ours = append(ours, known{e.Name(), e.IsDir()})
|
||||
continue
|
||||
}
|
||||
strangers = append(strangers, e.Name())
|
||||
}
|
||||
|
||||
if len(strangers) == 0 {
|
||||
p.remove(abs, CleanupKindWikiDir, evidence, true)
|
||||
return
|
||||
}
|
||||
// 降級:只刪認得出來的那幾個,目錄與陌生鄰居原封不動。
|
||||
for _, k := range ours {
|
||||
p.remove(filepath.Join(abs, k.name), CleanupKindWikiFile, evidence, k.isDir)
|
||||
}
|
||||
for _, s := range strangers {
|
||||
p.keep(filepath.Join(abs, s), "這個檔不在我們的帳本上、也沒有 arcrun- 標記——可能是你自己放進來的,留著")
|
||||
}
|
||||
}
|
||||
|
||||
// wikiEntryIsOurs 回答「`.wiki/` 裡的這一項是不是我們寫的」。四條路,全部要拿得出證據。
|
||||
func (p *cleanupPlanner) wikiEntryIsOurs(dir string, e os.DirEntry) bool {
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
return false // 捷徑一律不算我們的
|
||||
}
|
||||
abs := filepath.Join(dir, e.Name())
|
||||
if e.IsDir() {
|
||||
return false // 我們從來不在 .wiki/ 底下開子目錄
|
||||
}
|
||||
switch {
|
||||
case e.Name() == ".gitignore":
|
||||
return fileHasExactBody(abs, wikiIgnoreBody)
|
||||
case e.Name() == "manifest.json":
|
||||
return isOurWikiManifest(abs)
|
||||
case e.Name() == "00-INDEX.md":
|
||||
return fileHasPrefix(abs, "# 00-INDEX")
|
||||
case p.cards[abs]:
|
||||
return true // 帳本上有這張卡
|
||||
case IsMarked(e.Name()):
|
||||
return true // machinemark.go 的規約前綴
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planMarkedCards 處理 `system-dev/wiki/cards/`——那是**看得見**的卡片產物區
|
||||
// (監看根不在筆記庫也不在版控時的落點)。這裡不刪目錄,只刪帶標記的檔:
|
||||
// 那個目錄是 system-dev-template 的規約路徑,使用者自己的卡也住在那裡(#105 的教訓)。
|
||||
func (p *cleanupPlanner) planMarkedCards(abs string) {
|
||||
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if IsMarked(d.Name()) {
|
||||
p.remove(path, CleanupKindMarked, "檔名帶 arcrun- 標記——daemon 產生的每個檔都帶這個前綴(machinemark.go 規約)", false)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// collectWikiCardIndex 把樹裡每一份 `.wiki/manifest.json` 讀出來,攤成「這些絕對路徑是我們的卡」。
|
||||
//
|
||||
// 為什麼要掃整棵樹而不是只讀監看根那一份:實務上樹裡會有**別的監看根**留下的帳本
|
||||
// (`pms/pms_v1_legacy/.wiki/manifest.json`)。帳本裡的卡路徑是相對**它自己的根**
|
||||
// (=那個 `.wiki` 的上層目錄),所以每份帳本各自解析。
|
||||
func collectWikiCardIndex(absRoot string, others []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
var walk func(dir string)
|
||||
walk = func(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Type()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(dir, e.Name())
|
||||
if e.Name() == wikiRelDir {
|
||||
addManifestCards(out, abs)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
walk(abs)
|
||||
}
|
||||
}
|
||||
walk(absRoot)
|
||||
// 別的監看根底下那份帳本也要讀——它記的卡可能落在**我們這一側**(節點在共同祖先下)。
|
||||
// 讀帳本是純讀取,不代表會刪那邊的東西(刪不刪由 underOtherRoot 決定)。
|
||||
for _, o := range others {
|
||||
addManifestCards(out, filepath.Join(o, wikiRelDir))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addManifestCards(out map[string]bool, wikiDir string) {
|
||||
path := filepath.Join(wikiDir, "manifest.json")
|
||||
m, ok := readOurWikiManifest(path)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
owner := filepath.Dir(wikiDir) // 帳本裡的路徑相對這一層
|
||||
for _, d := range m.Docs {
|
||||
for _, c := range d.Cards {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" || strings.Contains(c, "..") || filepath.IsAbs(filepath.FromSlash(c)) {
|
||||
continue // 帳本被改壞時不讓它把我們指到樹外面
|
||||
}
|
||||
out[filepath.Join(owner, filepath.FromSlash(c))] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readOurWikiManifest(path string) (*wikiManifest, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var m wikiManifest
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return nil, false
|
||||
}
|
||||
// 認的是形狀,不是「檔名叫 manifest.json」:要有版本號,而且每筆 doc 都要有我們的鍵。
|
||||
if m.Version <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, d := range m.Docs {
|
||||
if d.Node == "" || d.DocID == "" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func isOurWikiManifest(path string) bool {
|
||||
_, ok := readOurWikiManifest(path)
|
||||
return ok
|
||||
}
|
||||
|
||||
// fileHasExactBody:檔案內容**逐字**等於 want。逐字比對是刻意的——「開頭像」不夠,
|
||||
// 有人可能在我們的宣告後面接自己的規則,那份 .gitignore 就不只是我們的了。
|
||||
func fileHasExactBody(path, want string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
return err == nil && string(data) == want
|
||||
}
|
||||
|
||||
func fileHasPrefix(path, want string) bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, len(want))
|
||||
n, _ := f.Read(buf)
|
||||
return n == len(want) && string(buf) == want
|
||||
}
|
||||
|
||||
// pathWithin 回答 child 是不是 parent 底下(不含 parent 自己)。純字串比對,
|
||||
// 呼叫端傳進來的都已經是 Abs+Clean 過的路徑。
|
||||
func pathWithin(parent, child string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel)
|
||||
}
|
||||
|
||||
func isEmptyDir(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
return err == nil && len(entries) == 0
|
||||
}
|
||||
|
||||
// countTree 算一筆刪除目標涵蓋幾個檔、多少位元組(給清單顯示「會刪掉 N 個檔」)。
|
||||
func countTree(abs string) (files int, bytes int64) {
|
||||
st, err := os.Lstat(abs)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return 1, st.Size()
|
||||
}
|
||||
_ = filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, ierr := d.Info(); ierr == nil {
|
||||
files++
|
||||
bytes += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, bytes
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// cleanup_cli.go — `collector cleanup` 子命令(arcrun-rag#138)。
|
||||
//
|
||||
// 為什麼 App 之外還要有一支 CLI:#138 的驗收條件是「使用者要能在動手前看到將要刪掉
|
||||
// 哪些東西」。畫面上那份清單與這支印出來的是**同一個 PlanCleanup**——
|
||||
// 一份判斷邏輯兩個出口,不會出現「畫面說會刪 A、實際刪了 B」。
|
||||
// 而且出事時(畫面按鈕壞掉、使用者不信任 GUI)他仍然有一條看得見全部細節的路。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// multiFlag 讓 --keep 可以重複給(其他還在看守的資料夾)。
|
||||
type multiFlag []string
|
||||
|
||||
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
||||
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
||||
|
||||
func runCleanup(args []string) int {
|
||||
fs2 := newFlagSet()
|
||||
folder := fs2.String("folder", "", "要斷連的資料夾(必填)")
|
||||
apply := fs2.Bool("apply", false, "真的刪(不加=只列清單,什麼都不動)")
|
||||
asJSON := fs2.Bool("json", false, "輸出 JSON(給程式讀)")
|
||||
var keep multiFlag
|
||||
fs2.Var(&keep, "keep", "其他還在看守的資料夾(可重複給)——它們底下的東西一律不碰")
|
||||
if err := fs2.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *folder == "" {
|
||||
fmt.Fprintln(os.Stderr, "錯誤:--folder 為必填")
|
||||
return 2
|
||||
}
|
||||
|
||||
var plan *CleanupPlan
|
||||
var res *CleanupResult
|
||||
var err error
|
||||
if *apply {
|
||||
plan, res, err = ApplyCleanup(*folder, keep)
|
||||
} else {
|
||||
plan, err = PlanCleanup(*folder, keep)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "collector cleanup:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if *asJSON {
|
||||
out := struct {
|
||||
Plan *CleanupPlan `json:"plan"`
|
||||
Result *CleanupResult `json:"result,omitempty"`
|
||||
}{plan, res}
|
||||
data, _ := json.MarshalIndent(out, "", " ")
|
||||
fmt.Println(string(data))
|
||||
if res != nil && len(res.Failed) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Println(plan.Root)
|
||||
if len(plan.Remove) == 0 {
|
||||
fmt.Println("沒有找到任何 Arcrun RAG 建立的東西——這個資料夾不需要清理。")
|
||||
} else {
|
||||
if !*apply {
|
||||
fmt.Println("※ 這只是清單,什麼都還沒刪。確認沒問題後,同一行指令加上 --apply 才會真的刪。")
|
||||
}
|
||||
fmt.Printf("會刪掉 %d 項(共 %d 個檔):\n", len(plan.Remove), plan.Files)
|
||||
for _, it := range plan.Remove {
|
||||
mark := "檔"
|
||||
if it.IsDir {
|
||||
mark = "目錄"
|
||||
}
|
||||
fmt.Printf(" 刪 %s %s(%d 個檔)\n 依據:%s\n", mark, it.Rel, it.Files, it.Evidence)
|
||||
}
|
||||
}
|
||||
if len(plan.Keep) > 0 {
|
||||
fmt.Printf("留著不動 %d 項:\n", len(plan.Keep))
|
||||
for _, k := range plan.Keep {
|
||||
fmt.Printf(" 留 %s\n 原因:%s\n", k.Rel, k.Reason)
|
||||
}
|
||||
}
|
||||
if res != nil {
|
||||
fmt.Printf("實際刪掉 %d 項 / %d 個檔。\n", len(res.Removed), res.Files)
|
||||
for _, f := range res.Failed {
|
||||
fmt.Printf(" ✗ 刪不掉 %s(%s)\n", f.Rel, f.Error)
|
||||
}
|
||||
if len(res.Failed) > 0 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
// cleanup_test.go — 斷連清理的迴歸網(arcrun-rag#138)。
|
||||
//
|
||||
// 這支測的重點**不是「有沒有刪掉」**,是「**有沒有刪到不該刪的**」。
|
||||
// 每一個 case 都對應一種「差一點就誤刪使用者知識」的擺法。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func cuWriteFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeWiki 造一個「長得跟 daemon 產的一模一樣」的 .wiki 目錄。
|
||||
func makeWiki(t *testing.T, dir string, cards ...string) {
|
||||
t.Helper()
|
||||
cuWriteFile(t, filepath.Join(dir, ".gitignore"), wikiIgnoreBody)
|
||||
cuWriteFile(t, filepath.Join(dir, "00-INDEX.md"), "# 00-INDEX\n\n## 文件\n")
|
||||
for _, c := range cards {
|
||||
cuWriteFile(t, filepath.Join(dir, c), "# "+strings.TrimSuffix(c, ".md")+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
// makeRootManifest 寫出監看根那一份 `.wiki/manifest.json`(帳本)。
|
||||
// nodeCards=節點 →「相對監看根」的卡片路徑,跟 daemon 實際寫的形狀一致
|
||||
// (2026-08-24 對照 `pms` 現場的真檔驗過:帳本涵蓋每一張卡,只有 00-INDEX.md 不在裡面)。
|
||||
func makeRootManifest(t *testing.T, root string, nodeCards map[string][]string) {
|
||||
t.Helper()
|
||||
m := &wikiManifest{Version: 1}
|
||||
for node, cards := range nodeCards {
|
||||
m.Docs = append(m.Docs, wikiDoc{
|
||||
Node: nodeKeyOf(node), Path: "來源.md", DocID: "doc-" + node + "-1",
|
||||
Status: "extracted", Cards: cards,
|
||||
})
|
||||
}
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cuWriteFile(t, filepath.Join(root, wikiRelDir, "manifest.json"), string(data)+"\n")
|
||||
}
|
||||
|
||||
func makeWorkspace(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
cuWriteFile(t, filepath.Join(root, workspaceRelDir, ".gitignore"), workspaceIgnoreBody)
|
||||
cuWriteFile(t, filepath.Join(root, workspaceRelDir, "wiki", "cards", "arcrun-資料夾總覽-x.md"), "# x\n")
|
||||
}
|
||||
|
||||
func relsOf(items []CleanupItem) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.Rel)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func keepRels(keeps []CleanupKeep) []string {
|
||||
out := make([]string, 0, len(keeps))
|
||||
for _, k := range keeps {
|
||||
out = append(out, k.Rel)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// snapshotTree 記下整棵樹每個檔的相對路徑與內容雜湊——用來證明「使用者的檔一個都沒動」。
|
||||
func cuSnapshotTree(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
data, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
out[filepath.ToSlash(rel)] = string(data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── ① 基本盤:多層巢狀的 .wiki 與工作區全部收掉,使用者的原稿一個不少 ──
|
||||
|
||||
func TestCleanupRemovesNestedProductsKeepsUserFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// 使用者的東西
|
||||
cuWriteFile(t, filepath.Join(root, "README.md"), "我的說明\n")
|
||||
cuWriteFile(t, filepath.Join(root, "docs", "設計.md"), "我的設計\n")
|
||||
cuWriteFile(t, filepath.Join(root, "docs", "deep", "更深的.md"), "我的深層檔\n")
|
||||
// daemon 的東西(三層)
|
||||
makeWorkspace(t, root)
|
||||
makeWiki(t, filepath.Join(root, wikiRelDir), "根卡.md")
|
||||
makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "設計卡.md")
|
||||
makeWiki(t, filepath.Join(root, "docs", "deep", wikiRelDir), "深卡.md")
|
||||
makeRootManifest(t, root, map[string][]string{
|
||||
"": {".wiki/根卡.md"},
|
||||
"docs": {"docs/.wiki/設計卡.md"},
|
||||
"docs/deep": {"docs/deep/.wiki/深卡.md"},
|
||||
})
|
||||
|
||||
before := cuSnapshotTree(t, root)
|
||||
|
||||
plan, res, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Failed) != 0 {
|
||||
t.Fatalf("不該有刪不掉的:%+v", res.Failed)
|
||||
}
|
||||
want := []string{".arcrun-rag", ".wiki", "docs/.wiki", "docs/deep/.wiki"}
|
||||
if got := relsOf(plan.Remove); !equalStrings(got, want) {
|
||||
t.Fatalf("要刪的清單不對\n got=%v\nwant=%v", got, want)
|
||||
}
|
||||
|
||||
after := cuSnapshotTree(t, root)
|
||||
// 使用者的檔:逐一比對「內容一字不差」。
|
||||
for _, rel := range []string{"README.md", "docs/設計.md", "docs/deep/更深的.md"} {
|
||||
if after[rel] != before[rel] {
|
||||
t.Fatalf("使用者的檔被動到了:%s(before=%q after=%q)", rel, before[rel], after[rel])
|
||||
}
|
||||
}
|
||||
// 剩下的檔**只能是**使用者那三個。
|
||||
if len(after) != 3 {
|
||||
t.Fatalf("清完之後應該只剩使用者的 3 個檔,實際剩 %d:%v", len(after), after)
|
||||
}
|
||||
for _, hidden := range []string{".wiki", ".arcrun-rag", "docs/.wiki", "docs/deep/.wiki"} {
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(hidden))); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s 沒被刪掉", hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② 陌生鄰居:.wiki 裡有一個不是我們的檔 ⇒ 整個目錄留下,只刪認得的 ──
|
||||
|
||||
func TestCleanupKeepsWikiDirWithStrangerFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wiki := filepath.Join(root, wikiRelDir)
|
||||
makeWiki(t, wiki, "卡.md")
|
||||
makeRootManifest(t, root, map[string][]string{"": {".wiki/卡.md"}})
|
||||
cuWriteFile(t, filepath.Join(wiki, "我自己寫的.md"), "這是我的\n")
|
||||
|
||||
plan, res, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res
|
||||
for _, it := range plan.Remove {
|
||||
if it.Kind == CleanupKindWikiDir {
|
||||
t.Fatalf("有陌生鄰居時不准整個目錄刪掉:%+v", it)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(wiki, "我自己寫的.md")); err != nil {
|
||||
t.Fatalf("使用者自己的檔被刪了:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(wiki); err != nil {
|
||||
t.Fatalf(".wiki 目錄不該被刪:%v", err)
|
||||
}
|
||||
// 帳本以外的檔(00-INDEX 與 .gitignore 是我們的)該刪掉。
|
||||
if _, err := os.Stat(filepath.Join(wiki, "00-INDEX.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("00-INDEX.md 應該被刪掉")
|
||||
}
|
||||
if !contains(keepRels(plan.Keep), ".wiki/我自己寫的.md") {
|
||||
t.Fatalf("留下的東西要出現在 Keep 清單上讓使用者看到:%v", keepRels(plan.Keep))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ 沒有我們宣告的 .wiki(使用者自己的同名目錄)⇒ 一個檔都不碰 ──
|
||||
|
||||
func TestCleanupIgnoresForeignWikiDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wiki := filepath.Join(root, wikiRelDir)
|
||||
cuWriteFile(t, filepath.Join(wiki, "我的筆記.md"), "我的\n")
|
||||
cuWriteFile(t, filepath.Join(wiki, "00-INDEX.md"), "# 00-INDEX\n") // 連名字都一樣也不行
|
||||
|
||||
before := cuSnapshotTree(t, root)
|
||||
plan, _, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Remove) != 0 {
|
||||
t.Fatalf("沒有我們的宣告就一個都不准刪:%v", relsOf(plan.Remove))
|
||||
}
|
||||
if got := cuSnapshotTree(t, root); len(got) != len(before) {
|
||||
t.Fatalf("樹被動到了:before=%v after=%v", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ④ .gitignore 被使用者改過(後面加了自己的規則)⇒ 不算我們的宣告 ──
|
||||
|
||||
func TestCleanupRequiresExactIgnoreBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wiki := filepath.Join(root, wikiRelDir)
|
||||
makeWiki(t, wiki, "卡.md")
|
||||
cuWriteFile(t, filepath.Join(wiki, ".gitignore"), wikiIgnoreBody+"\n!我要收版控的.md\n")
|
||||
|
||||
plan, err := PlanCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Remove) != 0 {
|
||||
t.Fatalf(".gitignore 被改過就不該認:%v", relsOf(plan.Remove))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑤ 工作區裡有 legacy-template 收容處 ⇒ 收容處留著,其餘刪 ──
|
||||
|
||||
func TestCleanupKeepsLegacyTemplateQuarantine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeWorkspace(t, root)
|
||||
quar := filepath.Join(root, legacyTemplateRelDir, "system-dev", "wiki", "status.md")
|
||||
cuWriteFile(t, quar, "使用者的舊 status\n")
|
||||
|
||||
plan, res, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Failed) != 0 {
|
||||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||||
}
|
||||
if _, err := os.Stat(quar); err != nil {
|
||||
t.Fatalf("收容處裡的檔被刪了——那是從使用者資料夾搬進來的:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, workspaceRelDir, "wiki")); !os.IsNotExist(err) {
|
||||
t.Fatalf("工作區裡我們自己的東西該刪掉")
|
||||
}
|
||||
if !contains(keepRels(plan.Keep), ".arcrun-rag/legacy-template") {
|
||||
t.Fatalf("收容處要出現在 Keep 清單:%v", keepRels(plan.Keep))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑥ 另一個還在看守的巢狀根 ⇒ 它底下的東西一個都不碰(2026-08-24 pms 現場擺法)──
|
||||
|
||||
func TestCleanupSkipsOtherWatchedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
inner := filepath.Join(root, "legacy")
|
||||
makeWorkspace(t, root)
|
||||
makeWiki(t, filepath.Join(root, wikiRelDir), "外卡.md")
|
||||
makeRootManifest(t, root, map[string][]string{"": {".wiki/外卡.md"}})
|
||||
makeWorkspace(t, inner)
|
||||
makeWiki(t, filepath.Join(inner, wikiRelDir), "內卡.md")
|
||||
makeWiki(t, filepath.Join(inner, "sub", wikiRelDir), "更內卡.md")
|
||||
makeRootManifest(t, inner, map[string][]string{
|
||||
"": {".wiki/內卡.md"},
|
||||
"sub": {"sub/.wiki/更內卡.md"},
|
||||
})
|
||||
|
||||
plan, res, err := ApplyCleanup(root, []string{inner})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Failed) != 0 {
|
||||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||||
}
|
||||
for _, rel := range relsOf(plan.Remove) {
|
||||
if strings.HasPrefix(rel, "legacy/") {
|
||||
t.Fatalf("動到另一個還在看守的根:%s", rel)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(inner, workspaceRelDir, ".gitignore"),
|
||||
filepath.Join(inner, wikiRelDir, "內卡.md"),
|
||||
filepath.Join(inner, "sub", wikiRelDir, "更內卡.md"),
|
||||
} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("還在看守的資料夾底下的東西被刪了:%s(%v)", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, wikiRelDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("外層自己的 .wiki 該刪掉")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑦ 看得見的卡片產物區:只刪帶 arcrun- 標記的檔,目錄與別人的卡不動 ──
|
||||
|
||||
func TestCleanupMarkedCardsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cards := filepath.Join(root, filepath.FromSlash(cardsRelDir))
|
||||
cuWriteFile(t, filepath.Join(cards, "arcrun-機器卡.md"), "# 機器卡\n")
|
||||
cuWriteFile(t, filepath.Join(cards, "leo自己的卡.md"), "# 我的\n")
|
||||
cuWriteFile(t, filepath.Join(root, "system-dev", "wiki", "status.md"), "我的 status\n")
|
||||
|
||||
_, res, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Failed) != 0 {
|
||||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cards, "arcrun-機器卡.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("帶標記的卡該刪掉")
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(cards, "leo自己的卡.md"),
|
||||
filepath.Join(root, "system-dev", "wiki", "status.md"),
|
||||
cards,
|
||||
} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("不該動的東西被動了:%s(%v)", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑧ symlink 不跟隨、不刪(指出去的連結會刪到監看根以外)──
|
||||
|
||||
func TestCleanupNeverFollowsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
cuWriteFile(t, filepath.Join(outside, "外面的重要檔.md"), "別碰\n")
|
||||
makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md")
|
||||
if err := os.Symlink(outside, filepath.Join(root, "捷徑")); err != nil {
|
||||
t.Skipf("這台建不了 symlink:%v", err)
|
||||
}
|
||||
// 連 .wiki 自己是條捷徑的情況也擋
|
||||
link2 := filepath.Join(root, "sub", wikiRelDir)
|
||||
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, link2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, res, err := ApplyCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Failed) != 0 {
|
||||
t.Fatalf("不該有失敗:%+v", res.Failed)
|
||||
}
|
||||
for _, rel := range relsOf(plan.Remove) {
|
||||
if strings.HasPrefix(rel, "捷徑") || strings.HasPrefix(rel, "sub/") {
|
||||
t.Fatalf("動到 symlink:%s", rel)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "外面的重要檔.md")); err != nil {
|
||||
t.Fatalf("監看根以外的檔被刪了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑨ PlanCleanup 保證不寫檔(「先看清單」的前提就是看清單這件事本身無副作用)──
|
||||
|
||||
func TestPlanCleanupWritesNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cuWriteFile(t, filepath.Join(root, "我的.md"), "x\n")
|
||||
makeWorkspace(t, root)
|
||||
makeWiki(t, filepath.Join(root, wikiRelDir), "卡.md")
|
||||
makeWiki(t, filepath.Join(root, "docs", wikiRelDir), "卡2.md")
|
||||
|
||||
before := cuSnapshotTree(t, root)
|
||||
plan, err := PlanCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Remove) == 0 {
|
||||
t.Fatal("這份擺法應該找得到東西")
|
||||
}
|
||||
after := cuSnapshotTree(t, root)
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("PlanCleanup 動了檔案:before=%d after=%d", len(before), len(after))
|
||||
}
|
||||
for k, v := range before {
|
||||
if after[k] != v {
|
||||
t.Fatalf("PlanCleanup 改了 %s", k)
|
||||
}
|
||||
}
|
||||
// 每一筆都要說得出依據——沒依據的東西不准進清單。
|
||||
for _, it := range plan.Remove {
|
||||
if strings.TrimSpace(it.Evidence) == "" {
|
||||
t.Fatalf("這一筆沒有依據:%+v", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ⑩ 帳本認卡:卡名不帶標記、也不是 00-INDEX,靠 manifest.json 認出來 ──
|
||||
|
||||
func TestCleanupAccountsCardsViaManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
wiki := filepath.Join(root, wikiRelDir)
|
||||
cuWriteFile(t, filepath.Join(wiki, "手動卡.md"), "# 手動卡\n")
|
||||
cuWriteFile(t, filepath.Join(wiki, "manifest.json"), `{"version":1,"docs":[
|
||||
{"node":"(根)","path":"a.md","doc_id":"abc","status":"extracted","cards":[".wiki/手動卡.md"]}]}`)
|
||||
// 故意不放 .gitignore ⇒ 只能靠帳本認人
|
||||
plan, err := PlanCleanup(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := relsOf(plan.Remove); !equalStrings(got, []string{".wiki"}) {
|
||||
t.Fatalf("帳本上的卡+帳本自己=全部認得,應該整個目錄刪掉,實際 %v(keep=%v)", got, keepRels(plan.Keep))
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
// cloud_audit.go — 本機帳本 ↔ 雲端索引對帳(`inkstone/arcrun-rag#140`)。
|
||||
//
|
||||
// 病(`inkstone/Arcrun#165` 斷點一,2026-08-26 實測):
|
||||
//
|
||||
// 本機 manifest 蓋的「已送成功」章**永遠不會過期**。雲端在 2026-08-14 被重裝/清空之後,
|
||||
// 那些檔案的 content_hash 沒變 ⇒ Scan() 不產生事件 ⇒ **永遠不會重送**,
|
||||
// 而且**沒有任何地方會說話**。使用者看到的是「檔案明明在資料夾裡,AI 卻查不到」。
|
||||
// 實據:youlinhsieh-test1 的 8 個檔 ingested_at 落在 07-29~08-06、零 fail_count,
|
||||
// 雲端 D1 的 MIN(created_at) 卻是 08-14 20:27 ⇒ 分界線乾淨得可怕。
|
||||
//
|
||||
// 解的形狀(**不是把兩邊砍掉重來**,那是紅線):
|
||||
//
|
||||
// 章不再是「我送過了」,而是「我送過了**而且雲端現在還有**」。
|
||||
// 後半句本機答不出來,只能去問雲端 ⇒ 這支檔就是那個問句。
|
||||
//
|
||||
// 為什麼問得起(不必等雲端先改):cypher-executor 早就有 `/kbdb/entries` proxy,
|
||||
// 認證用的正是 daemon 本來就帶著的 `X-Arcrun-API-Key`(routes/kbdb-proxy.ts,
|
||||
// owner_id 由 server 端強制注入,租戶跨不過去)。⇒ **零雲端改動、零新憑證。**
|
||||
//
|
||||
// 三條自我約束(對應票上的紅線):
|
||||
// - **不刪任何東西**:對帳只會把本機的章拔掉(讓它重走既有的送件路),
|
||||
// 不碰雲端 entry、不碰 .wiki 卡、不碰使用者的檔。
|
||||
// - **不做全量重送**:一次只問 cloudAuditBatch 個檔,且同一個檔
|
||||
// cloudAuditRecheckInterval 內只問一次;問到「雲端沒有」才拔章。
|
||||
// - **不會重送兩次**:拔過章的檔在 cloudAuditRepairGrace 內不再被拔第二次
|
||||
// (雲端 ingest 是非同步的,剛送出去那幾秒查不到是正常的——
|
||||
// 沒有這道閘,它會變成一個每輪重送、把額度燒光的迴圈,
|
||||
// 那就是「把一個 bug 換成另一個」)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cloudAuditRecheckInterval=同一個檔多久重新對一次帳。
|
||||
// 24 小時的理由:這是「雲端被清空」這種罕見事故的偵測延遲上限,
|
||||
// 而使用者按「立刻同步」(ForceSync)時本輪一律重對,不必等它。
|
||||
const cloudAuditRecheckInterval = 24 * time.Hour
|
||||
|
||||
// cloudAuditRepairGrace=同一個檔拔過章之後,多久內不准再拔第二次。
|
||||
//
|
||||
// 🔴 這是「重跑第二次不可以又全部重送一遍」(#140 驗收條件 5)的那道閘。
|
||||
// 雲端 rag_ingest_card 是觸發式的,卡片不會在 POST 回來的那一瞬間就查得到;
|
||||
// 沒有這個窗口,對帳會在下一輪又判它「不在」⇒ 重送 ⇒ 無限迴圈 ⇒ 燒光額度。
|
||||
const cloudAuditRepairGrace = 24 * time.Hour
|
||||
|
||||
// cloudAuditBatch=單輪最多問幾個檔。
|
||||
// 與 DefaultMaxEventsPerRun 同一個精神:巨量積壓(實據 27,164 檔)不該一輪湧完。
|
||||
const cloudAuditBatch = 20
|
||||
|
||||
// cloudAuditFolderInterval=同一個資料夾兩次對帳批次的最小間隔。
|
||||
// daemon 預設 5 秒一輪,沒有這道節流就是每 5 秒 20 個請求。
|
||||
const cloudAuditFolderInterval = 60 * time.Second
|
||||
|
||||
// cloudAuditProbePace=批次內每個請求之間的間隔。
|
||||
// 比 directPaceInterval(700ms)短很多是因為這是 D1 的單筆 COUNT,不是 LLM——
|
||||
// 20 個請求約 2 秒跑完,不會讓一輪掃描明顯變慢。
|
||||
const cloudAuditProbePace = 100 * time.Millisecond
|
||||
|
||||
// cloudAuditHTTP:對帳是唯讀查詢,逾時要短——問不到就當「這輪沒查」,
|
||||
// 絕不能因為雲端慢就把整輪同步拖住(同 cloud_version.go 的判準)。
|
||||
var cloudAuditHTTP = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// probeCloudCard 可在測試中替換,避免真實網路呼叫。
|
||||
var probeCloudCard = cloudCardPresent
|
||||
|
||||
// cloudCardPresent 問雲端「這個庫裡還有沒有這個檔的卡」。
|
||||
//
|
||||
// 判準=`metadata_json.source == kb://<相對路徑>#0`。為什麼是 `#0` 而不是頁名:
|
||||
// - 頁名是 basename 去副檔名(pageNameOf),**兩個資料夾裡的同名檔會撞在一起**;
|
||||
// - `source` 是 rag_ingest_card parse_card 寫死的 `kb://<path>#<第幾塊>`,
|
||||
// 而第 0 塊必然存在(卡片至少一塊)⇒ 它就是「這張卡在不在」的存在性鍵,
|
||||
// 也正是 rag_takedown_direct 用來比對的同一個鍵(兩邊用同一把尺)。
|
||||
//
|
||||
// `offset` 故意帶一個大數:回應只要 `total`,不要 entries 本身——
|
||||
// 卡片內文動輒數 KB,20 個檔就是幾百 KB 的白搭流量。
|
||||
//
|
||||
// 回傳的 ok=false=**這次沒查成**(連不上、非 2xx、回應不是 JSON)。
|
||||
// 呼叫端必須把它當「不知道」,**不准當成「雲端沒有」**——
|
||||
// 那會讓一次網路抖動變成一次全量重送。
|
||||
func cloudCardPresent(cfg *DirectConfig, library, relPath string) (present bool, ok bool, err error) {
|
||||
base := strings.TrimSuffix(cfg.CypherURL, "/")
|
||||
if base == "" || strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return false, false, fmt.Errorf("沒有雲端連線資訊(cypher_url/api_key 是空的)")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("source", "kb://"+relPath+"#0")
|
||||
q.Set("entry_type", "block")
|
||||
q.Set("limit", "1")
|
||||
q.Set("offset", "1000000") // 只要 total,不要把卡片內文整包拉下來
|
||||
if strings.TrimSpace(library) != "" {
|
||||
q.Set("library", library)
|
||||
}
|
||||
// #153:對帳一輪可能連打 cloudAuditBatch(20)發。「一發卡住」的代價在這裡
|
||||
// 會被乘上批次大小 ⇒ 20 秒的 client 逾時最壞就是 400 秒,而這期間畫面一個字
|
||||
// 都不會說。掛上等待閘:等到超時就記帳,同一個帳號連續等不到就整輪不再問。
|
||||
gate := cfg.openGate(stepCloudAudit)
|
||||
defer gate.release()
|
||||
if note := gate.blocked(); note != "" {
|
||||
return false, false, errors.New(note)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(gate.ctx, http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Arcrun-API-Key", cfg.APIKey)
|
||||
resp, err := cloudAuditHTTP.Do(req)
|
||||
if err != nil {
|
||||
return false, false, gate.record(err)
|
||||
}
|
||||
gate.ok()
|
||||
defer resp.Body.Close()
|
||||
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return false, false, fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if rerr != nil {
|
||||
return false, false, rerr
|
||||
}
|
||||
var payload struct {
|
||||
Success *bool `json:"success"`
|
||||
Total *int `json:"total"`
|
||||
}
|
||||
if jerr := json.Unmarshal(body, &payload); jerr != nil {
|
||||
return false, false, fmt.Errorf("雲端回應不是 JSON:%s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
// 🔴 total 缺席時回 ok=false 而不是「total=0」。少一個欄位可能是端點換了形狀,
|
||||
// 把它讀成「雲端沒有這個檔」就會全部重送——寧可什麼都不做。
|
||||
if payload.Total == nil || (payload.Success != nil && !*payload.Success) {
|
||||
return false, false, fmt.Errorf("雲端回應少了 total 欄位:%s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
return *payload.Total > 0, true, nil
|
||||
}
|
||||
|
||||
// auditCandidates 挑出這一輪要問的檔:蓋過章、且太久沒對過帳的,最久沒對的排前面。
|
||||
//
|
||||
// 三種**不問**(每一種都對應一個會咬人的情境):
|
||||
// - 沒蓋過章(IngestedHash 空):雲端本來就不該有它,Scan() 自己會重送。
|
||||
// - NoCloudCard:萃取判定「無可萃取概念」⇒ 本來就沒送過卡上雲。
|
||||
// 不排除它的話,對帳每次都會說「雲端沒有」⇒ 每天重萃一次,永遠停不下來。
|
||||
// - 剛拔過章還在 grace 內:見 cloudAuditRepairGrace。
|
||||
func auditCandidates(m *Manifest, now int64, force bool) []string {
|
||||
recheck := int64(cloudAuditRecheckInterval.Seconds())
|
||||
grace := int64(cloudAuditRepairGrace.Seconds())
|
||||
var out []string
|
||||
for p, e := range m.Entries {
|
||||
if e == nil || strings.TrimSpace(e.IngestedHash) == "" || e.NoCloudCard {
|
||||
continue
|
||||
}
|
||||
if e.CloudMissingAt > 0 && now-e.CloudMissingAt < grace {
|
||||
continue
|
||||
}
|
||||
if !force && e.CloudCheckedAt > 0 && now-e.CloudCheckedAt < recheck {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
// 最久沒對帳的先問(0=從沒問過,天然排最前);同分時按路徑排序,讓同一份輸入
|
||||
// 永遠得到同一個輸出(可測)。
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ai, aj := m.Entries[out[i]].CloudCheckedAt, m.Entries[out[j]].CloudCheckedAt
|
||||
if ai != aj {
|
||||
return ai < aj
|
||||
}
|
||||
return out[i] < out[j]
|
||||
})
|
||||
if len(out) > cloudAuditBatch {
|
||||
out = out[:cloudAuditBatch]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditResult=一輪對帳的結果,給呼叫端組人話與記帳用。
|
||||
type auditResult struct {
|
||||
Checked int // 這輪真的問了幾個檔
|
||||
Voided int // 其中幾個雲端查不到、章被拔掉(=已排進重送佇列)
|
||||
Err string // 對帳本身失敗的真因(原文,不改寫——leo 2026-08-06 原則)
|
||||
}
|
||||
|
||||
// auditCloudLedger 對一個監看根跑一輪對帳。回 nil=這輪不必對(節流中/沒有候選)。
|
||||
//
|
||||
// 🔴 它**只做一件事**:把「雲端已經沒有了」的章拔掉。
|
||||
// 拔完之後,接下來的 Scan() 會因為 `orig[p].IngestedHash == ""` 自然補一發 added
|
||||
// (scan.go 步驟 4「上輪偵測過但 ingest 未成功 → 重試」),
|
||||
// 於是重送走的是**既有的**萃取路:既有的單輪上限、既有的失敗退避、既有的額度冷卻
|
||||
// 全部照舊生效。⇒ 沒有第二條送件路,也沒有第二套節流要維護。
|
||||
func auditCloudLedger(cfg *DirectConfig, absRoot string, m *Manifest, dryRun bool, now time.Time) *auditResult {
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if !cfg.ForceSync && m.CloudAuditAt > 0 &&
|
||||
nowUnix-m.CloudAuditAt < int64(cloudAuditFolderInterval.Seconds()) {
|
||||
return nil // 節流:同一個資料夾一分鐘內不重問
|
||||
}
|
||||
cands := auditCandidates(m, nowUnix, cfg.ForceSync)
|
||||
if len(cands) == 0 {
|
||||
// 沒有候選也要記時間,否則每輪都要重走一次上面那個迴圈。
|
||||
m.CloudAuditAt = nowUnix
|
||||
return nil
|
||||
}
|
||||
lib := cfg.libraryFor(absRoot)
|
||||
res := &auditResult{}
|
||||
for _, p := range cands {
|
||||
e := m.Entries[p]
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
present, ok, err := probeCloudCard(cfg, lib, p)
|
||||
if !ok {
|
||||
// 問不到就整批停手:連不上時繼續問剩下的 19 個只是重複同一個錯誤。
|
||||
// **絕不把「沒查成」當成「雲端沒有」**(那會讓一次網路抖動變成全量重送)。
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
}
|
||||
break
|
||||
}
|
||||
res.Checked++
|
||||
e.CloudCheckedAt = nowUnix
|
||||
if present {
|
||||
continue
|
||||
}
|
||||
// 雲端真的沒有 ⇒ 這個章是假的,拔掉。**不動使用者的任何東西。**
|
||||
e.IngestedHash = ""
|
||||
e.CloudMissingAt = nowUnix
|
||||
// 失敗退避是「這個檔壞掉」的病歷,跟「雲端被清空」無關——留著會讓補送
|
||||
// 卡在上一次的退避階梯裡(實測那 8 個檔 fail_count 全是 0,但別的資料夾不一定)。
|
||||
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
|
||||
res.Voided++
|
||||
pace2(cloudAuditProbePace)
|
||||
}
|
||||
m.CloudAuditAt = nowUnix
|
||||
if res.Checked == 0 && res.Err == "" {
|
||||
return nil
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// ResyncSummary 從 manifest 現況算出「補送」這件事現在講到哪裡」。
|
||||
//
|
||||
// 為什麼是**從 manifest 重算**而不是記一個計數器:計數器是「本輪做了幾件事」,
|
||||
// 沒事做的那輪就會歸零——2026-08-05 leo 實撞的「明明做完了畫面卻永遠寫等待中」
|
||||
// 就是那個形狀。這裡算的是**現況**(還有幾份沒補回來),補完自然歸零、不必有人去清。
|
||||
func ResyncSummary(m *Manifest, now time.Time) (pending, repaired int) {
|
||||
nowUnix := now.Unix()
|
||||
fresh := int64(cloudAuditRepairGrace.Seconds())
|
||||
for _, e := range m.Entries {
|
||||
if e == nil || e.CloudMissingAt == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(e.IngestedHash) == "" {
|
||||
pending++ // 章拔掉了、還沒補送成功
|
||||
} else if e.IngestedAt >= e.CloudMissingAt && nowUnix-e.IngestedAt < fresh {
|
||||
repaired++ // 剛補送成功(只講最近一天的,不然這行會永遠掛在畫面上)
|
||||
}
|
||||
}
|
||||
return pending, repaired
|
||||
}
|
||||
|
||||
// resyncNote 把上面兩個數字翻成使用者看得懂的一句話。
|
||||
//
|
||||
// leo 2026-08-06 的原則(「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤」)
|
||||
// 在這裡的形狀是:**不能靜悄悄地重送**。使用者只要看到「有 8 份在補送」,
|
||||
// 就知道畫面上的數字為什麼會動、也知道不是自己弄壞的。
|
||||
func resyncNote(pending, repaired int, lastErr string) string {
|
||||
switch {
|
||||
case pending > 0 && repaired > 0:
|
||||
return fmt.Sprintf("雲端上找不到先前送過的檔案(知識庫可能重裝過),正在自動補送:已補回 %d 份,還有 %d 份排隊中。", repaired, pending)
|
||||
case pending > 0:
|
||||
return fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(知識庫可能重裝過),已排進佇列自動補送,你不必做任何事。", pending)
|
||||
case repaired > 0:
|
||||
return fmt.Sprintf("已把雲端上遺失的 %d 份檔案補送回知識庫。", repaired)
|
||||
case lastErr != "":
|
||||
return "暫時無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試)。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pace2=可指定長度的節流(directPaceInterval 那支是萃取路專用的固定 700ms)。
|
||||
// 拉成獨立函式是為了讓測試能把它調成 0。
|
||||
var pace2 = func(d time.Duration) {
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 每個測試都把節流關掉,避免 20 個探測睡 2 秒。
|
||||
func init() { pace2 = func(time.Duration) {} }
|
||||
|
||||
func newAuditManifest(paths ...string) *Manifest {
|
||||
m := &Manifest{FolderID: "f", Root: "/root", Entries: map[string]*ManifestEntry{}}
|
||||
for _, p := range paths {
|
||||
m.Entries[p] = &ManifestEntry{
|
||||
ContentHash: "sha256:" + p,
|
||||
IngestedHash: "sha256:" + p,
|
||||
IngestedAt: 100,
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// stubProbe 讓測試指定「雲端有哪些」,並記錄被問了幾次。
|
||||
func stubProbe(t *testing.T, present map[string]bool, err error) *int {
|
||||
t.Helper()
|
||||
calls := 0
|
||||
orig := probeCloudCard
|
||||
probeCloudCard = func(cfg *DirectConfig, library, relPath string) (bool, bool, error) {
|
||||
calls++
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return present[relPath], true, nil
|
||||
}
|
||||
t.Cleanup(func() { probeCloudCard = orig })
|
||||
return &calls
|
||||
}
|
||||
|
||||
func auditCfg() *DirectConfig {
|
||||
return &DirectConfig{CypherURL: "https://x.invalid", APIKey: "ns", Namespace: "ns"}
|
||||
}
|
||||
|
||||
// 本票的主場景:雲端被清空 ⇒ 章全部作廢 ⇒ Scan() 補發 added ⇒ 重送。
|
||||
func TestAudit_CloudWiped_VoidsStampsAndScanReemits(t *testing.T) {
|
||||
m := newAuditManifest("a.md", "b.pdf")
|
||||
stubProbe(t, map[string]bool{}, nil) // 雲端什麼都沒有
|
||||
now := time.Unix(1_000_000, 0)
|
||||
|
||||
res := auditCloudLedger(auditCfg(), "/root", m, false, now)
|
||||
if res == nil || res.Voided != 2 || res.Checked != 2 {
|
||||
t.Fatalf("預期對帳 2 份、作廢 2 份,實得 %+v", res)
|
||||
}
|
||||
for p, e := range m.Entries {
|
||||
if e.IngestedHash != "" {
|
||||
t.Fatalf("%s 的章沒有被拔掉", p)
|
||||
}
|
||||
if e.CloudMissingAt != now.Unix() {
|
||||
t.Fatalf("%s 沒有記下 cloud_missing_at", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 這才是真正的驗收:拔章之後 Scan() 要自己補發 added(不必另造送件路)。
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "a.md", "hello", time.Unix(900_000, 0))
|
||||
writeFile(t, root, "b.pdf", "world", time.Unix(900_000, 0))
|
||||
m2 := newAuditManifest("a.md", "b.pdf")
|
||||
for _, e := range m2.Entries {
|
||||
e.IngestedHash = "" // 對帳剛拔掉的狀態
|
||||
}
|
||||
// content_hash 讓 Scan 自己重算,這裡只要它認得出「這兩個路徑還沒 ingest 成功」。
|
||||
payload, err := Scan(root, m2, ScanOptions{MaxRemovedRatio: 0.4})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
added := map[string]bool{}
|
||||
for _, ev := range payload.Events {
|
||||
if ev.Type == "added" {
|
||||
added[ev.Path] = true
|
||||
}
|
||||
}
|
||||
if !added["a.md"] || !added["b.pdf"] {
|
||||
t.Fatalf("拔章後 Scan 沒有補發 added,實得 %+v", payload.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// 驗收條件 5:重跑第二次不可以又全部重送一遍。
|
||||
func TestAudit_SecondRunDoesNotVoidAgain(t *testing.T) {
|
||||
m := newAuditManifest("a.md")
|
||||
stubProbe(t, map[string]bool{}, nil)
|
||||
now := time.Unix(1_000_000, 0)
|
||||
|
||||
if r := auditCloudLedger(auditCfg(), "/root", m, false, now); r == nil || r.Voided != 1 {
|
||||
t.Fatalf("第一輪應該作廢 1 份,實得 %+v", r)
|
||||
}
|
||||
// 模擬補送成功(雲端仍在 ingest 中、查不到)
|
||||
m.MarkIngestedBy("a.md", "sha256:a.md", now.Unix()+1, "workers-ai")
|
||||
|
||||
// 一小時後再對帳:雲端還是查不到,但 grace 內**不准**再拔一次章。
|
||||
later := now.Add(time.Hour)
|
||||
m.CloudAuditAt = 0 // 解除資料夾層節流,只考驗 per-path grace
|
||||
r2 := auditCloudLedger(auditCfg(), "/root", m, false, later)
|
||||
if r2 != nil && r2.Voided > 0 {
|
||||
t.Fatalf("grace 期間又拔了章 ⇒ 會變成無限重送迴圈:%+v", r2)
|
||||
}
|
||||
if m.Entries["a.md"].IngestedHash == "" {
|
||||
t.Fatal("章被重複拔掉了")
|
||||
}
|
||||
}
|
||||
|
||||
// 雲端有的檔不准被動到(否則就是全量重送)。
|
||||
func TestAudit_PresentCardsUntouched(t *testing.T) {
|
||||
m := newAuditManifest("a.md", "b.md")
|
||||
stubProbe(t, map[string]bool{"a.md": true, "b.md": true}, nil)
|
||||
now := time.Unix(1_000_000, 0)
|
||||
|
||||
res := auditCloudLedger(auditCfg(), "/root", m, false, now)
|
||||
if res == nil || res.Voided != 0 || res.Checked != 2 {
|
||||
t.Fatalf("預期 0 作廢、2 已對帳,實得 %+v", res)
|
||||
}
|
||||
for p, e := range m.Entries {
|
||||
if e.IngestedHash == "" {
|
||||
t.Fatalf("%s 的章被誤拔", p)
|
||||
}
|
||||
if e.CloudCheckedAt != now.Unix() {
|
||||
t.Fatalf("%s 沒記下對帳時間 ⇒ 下一輪會重問,變成請求風暴", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 網路抖動不准被讀成「雲端沒有」——那會讓一次逾時變成一次全量重送。
|
||||
func TestAudit_ProbeFailureNeverVoids(t *testing.T) {
|
||||
m := newAuditManifest("a.md", "b.md")
|
||||
calls := stubProbe(t, nil, fmt.Errorf("connection reset"))
|
||||
res := auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000, 0))
|
||||
if res == nil || res.Voided != 0 || res.Err == "" {
|
||||
t.Fatalf("查不到時不該作廢、且要留下真因,實得 %+v", res)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("問不到就該整批停手(只問 1 次),實得 %d 次", *calls)
|
||||
}
|
||||
for p, e := range m.Entries {
|
||||
if e.IngestedHash == "" {
|
||||
t.Fatalf("%s 因為一次網路錯誤就被作廢", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 從沒送成功過的檔不必問雲端(Scan 自己會重送),沒送過卡的檔更不能問(會無限重萃)。
|
||||
func TestAudit_SkipsUnstampedAndNoCardEntries(t *testing.T) {
|
||||
m := newAuditManifest("stamped.md", "unstamped.md", "nocard.md")
|
||||
m.Entries["unstamped.md"].IngestedHash = ""
|
||||
m.Entries["nocard.md"].NoCloudCard = true
|
||||
calls := stubProbe(t, map[string]bool{}, nil)
|
||||
|
||||
auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000, 0))
|
||||
if *calls != 1 {
|
||||
t.Fatalf("只該問 stamped.md 一個,實得 %d 次", *calls)
|
||||
}
|
||||
if m.Entries["nocard.md"].IngestedHash == "" {
|
||||
t.Fatal("沒送過卡的檔被作廢 ⇒ 會每天重萃一次,永遠停不下來")
|
||||
}
|
||||
}
|
||||
|
||||
// 單輪上限+最久沒對帳的排前面(巨量資料夾也要輪得完)。
|
||||
func TestAudit_BatchCapAndOldestFirst(t *testing.T) {
|
||||
m := &Manifest{FolderID: "f", Root: "/root", Entries: map[string]*ManifestEntry{}}
|
||||
for i := 0; i < cloudAuditBatch+5; i++ {
|
||||
p := fmt.Sprintf("f%02d.md", i)
|
||||
m.Entries[p] = &ManifestEntry{
|
||||
ContentHash: "h", IngestedHash: "h",
|
||||
CloudCheckedAt: int64(i), // 越前面越久沒對
|
||||
}
|
||||
}
|
||||
var asked []string
|
||||
orig := probeCloudCard
|
||||
probeCloudCard = func(cfg *DirectConfig, library, relPath string) (bool, bool, error) {
|
||||
asked = append(asked, relPath)
|
||||
return true, true, nil
|
||||
}
|
||||
t.Cleanup(func() { probeCloudCard = orig })
|
||||
|
||||
// force=true 才會忽略「24 小時內對過帳」的閘(上面的 CloudCheckedAt 是很小的數字,
|
||||
// 對現在的時間來說早就過期了,這裡用 force 讓意圖明確)。
|
||||
cfg := auditCfg()
|
||||
cfg.ForceSync = true
|
||||
auditCloudLedger(cfg, "/root", m, false, time.Unix(1_000_000, 0))
|
||||
if len(asked) != cloudAuditBatch {
|
||||
t.Fatalf("單輪上限沒生效:問了 %d 個(上限 %d)", len(asked), cloudAuditBatch)
|
||||
}
|
||||
if asked[0] != "f00.md" {
|
||||
t.Fatalf("最久沒對帳的沒排最前面,第一個是 %s", asked[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 資料夾層節流:daemon 每 5 秒一輪,不能每輪都對雲端發一批請求。
|
||||
func TestAudit_FolderIntervalThrottle(t *testing.T) {
|
||||
m := newAuditManifest("a.md")
|
||||
calls := stubProbe(t, map[string]bool{"a.md": true}, nil)
|
||||
now := time.Unix(1_000_000, 0)
|
||||
|
||||
auditCloudLedger(auditCfg(), "/root", m, false, now)
|
||||
auditCloudLedger(auditCfg(), "/root", m, false, now.Add(5*time.Second))
|
||||
if *calls != 1 {
|
||||
t.Fatalf("節流沒生效:5 秒內問了 %d 次", *calls)
|
||||
}
|
||||
// 使用者按「立刻同步」要能穿透節流。
|
||||
cfg := auditCfg()
|
||||
cfg.ForceSync = true
|
||||
auditCloudLedger(cfg, "/root", m, false, now.Add(6*time.Second))
|
||||
if *calls != 2 {
|
||||
t.Fatalf("立刻同步沒有穿透節流:共問了 %d 次", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 24 小時內對過帳的檔不重問(一般輪次)。
|
||||
func TestAudit_RecheckInterval(t *testing.T) {
|
||||
m := newAuditManifest("a.md")
|
||||
m.Entries["a.md"].CloudCheckedAt = 1_000_000
|
||||
calls := stubProbe(t, map[string]bool{"a.md": true}, nil)
|
||||
auditCloudLedger(auditCfg(), "/root", m, false, time.Unix(1_000_000+3600, 0))
|
||||
if *calls != 0 {
|
||||
t.Fatalf("一小時前才對過帳,不該重問(實得 %d 次)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// dryRun 不准碰帳本。
|
||||
func TestAudit_DryRunDoesNothing(t *testing.T) {
|
||||
m := newAuditManifest("a.md")
|
||||
calls := stubProbe(t, map[string]bool{}, nil)
|
||||
if r := auditCloudLedger(auditCfg(), "/root", m, true, time.Unix(1_000_000, 0)); r != nil {
|
||||
t.Fatalf("dry-run 不該做任何事,實得 %+v", r)
|
||||
}
|
||||
if *calls != 0 || m.Entries["a.md"].IngestedHash == "" || m.CloudAuditAt != 0 {
|
||||
t.Fatal("dry-run 動了帳本或打了網路")
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 scan.go 的 carry 段漏欄位是這個檔案歷史上犯過兩次的錯(t195 fail_count、5fcc139
|
||||
// LastError)。第三次的代價是「防重送迴圈的 grace 消失 ⇒ 燒光額度」,所以釘死它。
|
||||
func TestScan_CarriesCloudAuditFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, root, "a.md", "hello", time.Unix(900_000, 0))
|
||||
m := &Manifest{FolderID: "f", Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
if _, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.4}); err != nil {
|
||||
t.Fatalf("首輪 Scan: %v", err)
|
||||
}
|
||||
e := m.Entries["a.md"]
|
||||
e.IngestedHash = e.ContentHash
|
||||
e.CloudCheckedAt = 111
|
||||
e.CloudMissingAt = 222
|
||||
e.NoCloudCard = true
|
||||
|
||||
if _, err := Scan(root, m, ScanOptions{MaxRemovedRatio: 0.4}); err != nil {
|
||||
t.Fatalf("次輪 Scan: %v", err)
|
||||
}
|
||||
got := m.Entries["a.md"]
|
||||
if got.CloudCheckedAt != 111 || got.CloudMissingAt != 222 || !got.NoCloudCard {
|
||||
t.Fatalf("Scan 重建 entry 時把雲端對帳欄位抹掉了:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkIngestedBy 要保住 grace 標記(不然剛補送成功的檔立刻又回到可拔章的池子),
|
||||
// 但要把 NoCloudCard 歸位(這一輪送了卡就不是「沒卡的檔」了)。
|
||||
func TestMarkIngested_KeepsGraceClearsNoCard(t *testing.T) {
|
||||
m := newAuditManifest("a.md")
|
||||
e := m.Entries["a.md"]
|
||||
e.CloudCheckedAt, e.CloudMissingAt, e.NoCloudCard = 111, 222, true
|
||||
m.MarkIngestedBy("a.md", "h2", 999, "workers-ai")
|
||||
if e.CloudCheckedAt != 111 || e.CloudMissingAt != 222 {
|
||||
t.Fatalf("對帳記錄被清掉了:%+v", e)
|
||||
}
|
||||
if e.NoCloudCard {
|
||||
t.Fatal("NoCloudCard 沒有歸位")
|
||||
}
|
||||
if !m.MarkNoCloudCard("a.md") || !e.NoCloudCard {
|
||||
t.Fatal("MarkNoCloudCard 沒生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 補送進度是「現況快照」而不是「本輪計數」——沒事做的那輪不能歸零、補完要自然消失。
|
||||
func TestResyncSummary(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
m := newAuditManifest("pending.md", "repaired.md", "normal.md")
|
||||
m.Entries["pending.md"].IngestedHash = ""
|
||||
m.Entries["pending.md"].CloudMissingAt = now.Unix() - 60
|
||||
m.Entries["repaired.md"].CloudMissingAt = now.Unix() - 60
|
||||
m.Entries["repaired.md"].IngestedAt = now.Unix() - 30
|
||||
|
||||
pending, repaired := ResyncSummary(m, now)
|
||||
if pending != 1 || repaired != 1 {
|
||||
t.Fatalf("預期 pending=1 repaired=1,實得 %d/%d", pending, repaired)
|
||||
}
|
||||
if note := resyncNote(pending, repaired, ""); !strings.Contains(note, "補送") {
|
||||
t.Fatalf("人話沒生出來:%q", note)
|
||||
}
|
||||
// 一天後那句「已補回」要自己消失,不能永遠掛在畫面上。
|
||||
_, repairedLater := ResyncSummary(m, now.Add(25*time.Hour))
|
||||
if repairedLater != 0 {
|
||||
t.Fatalf("舊的補送紀錄沒有過期:%d", repairedLater)
|
||||
}
|
||||
}
|
||||
|
||||
// cloudCardPresent 打的是真的 HTTP,形狀對不對用假伺服器釘住
|
||||
// (URL 形狀一旦漂掉,對帳會靜默地永遠回「雲端沒有」=全量重送)。
|
||||
func TestCloudCardPresent_RequestShapeAndParsing(t *testing.T) {
|
||||
var got url.Values
|
||||
var gotKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.URL.Query()
|
||||
gotKey = r.Header.Get("X-Arcrun-API-Key")
|
||||
if r.URL.Path != "/kbdb/entries" {
|
||||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "entries": []any{}, "count": 0, "total": 1})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "ns"}
|
||||
present, ok, err := cloudCardPresent(cfg, "mylib", "docs/小果被AFTEE詐貸.pdf")
|
||||
if err != nil || !ok || !present {
|
||||
t.Fatalf("total=1 應該讀成 present,實得 present=%v ok=%v err=%v", present, ok, err)
|
||||
}
|
||||
if got.Get("source") != "kb://docs/小果被AFTEE詐貸.pdf#0" {
|
||||
t.Fatalf("source 鍵不對:%q", got.Get("source"))
|
||||
}
|
||||
if got.Get("library") != "mylib" || got.Get("entry_type") != "block" {
|
||||
t.Fatalf("庫/型別 filter 不對:%v", got)
|
||||
}
|
||||
if got.Get("offset") == "" || got.Get("offset") == "0" {
|
||||
t.Fatalf("沒有用大 offset 避開卡片內文:%q", got.Get("offset"))
|
||||
}
|
||||
if gotKey != "ns" {
|
||||
t.Fatalf("沒帶 X-Arcrun-API-Key:%q", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudCardPresent_ZeroTotalIsMissing(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "total": 0})
|
||||
}))
|
||||
defer srv.Close()
|
||||
present, ok, err := cloudCardPresent(&DirectConfig{CypherURL: srv.URL, APIKey: "ns"}, "lib", "a.md")
|
||||
if err != nil || !ok || present {
|
||||
t.Fatalf("total=0 應該讀成 missing,實得 present=%v ok=%v err=%v", present, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 端點換了形狀(少了 total)要回「不知道」,不准回「雲端沒有」。
|
||||
func TestCloudCardPresent_MissingTotalIsUnknown(t *testing.T) {
|
||||
cases := map[string]http.HandlerFunc{
|
||||
"沒有 total 欄位": func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "entries": []any{}})
|
||||
},
|
||||
"success=false": func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "total": 0, "error": "boom"})
|
||||
},
|
||||
"不是 JSON": func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("<html>cloudflare</html>"))
|
||||
},
|
||||
"401": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":"缺少 X-Arcrun-API-Key header"}`))
|
||||
},
|
||||
}
|
||||
for name, h := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
_, ok, err := cloudCardPresent(&DirectConfig{CypherURL: srv.URL, APIKey: "ns"}, "lib", "a.md")
|
||||
if ok {
|
||||
t.Fatalf("%s 應該回 ok=false(不知道),不能回「雲端沒有」", name)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%s 沒有留下真因", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudCardPresent_NoConnectionInfo(t *testing.T) {
|
||||
if _, ok, _ := cloudCardPresent(&DirectConfig{}, "lib", "a.md"); ok {
|
||||
t.Fatal("沒有連線資訊時不該宣稱查得到答案")
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,17 @@ dist-msix/
|
||||
# Python 匯入快取(daemon-version.py 被 import 時產生)。可重現的產物,且綁 Python 版本
|
||||
# (檔名帶 cpython-314)⇒ 不進版控。2026-08-18 D95 第三輪:第一輪搬檔時誤把一顆帶了進來。
|
||||
__pycache__/
|
||||
|
||||
# 🔴 `go build ./...` 在本目錄留下的執行檔(inkstone/arcrun-rag#158,2026-08-28)。
|
||||
# Go 對「單一 main 套件」會把成品寫成**目錄名**,所以任何人在這裡跑一次 go build
|
||||
# 就會生出 `arcrun-app` 這顆 20 MB 的二進位。它一路被追蹤到 2026-08-28 為止。
|
||||
#
|
||||
# 為什麼非拿掉不可(實測,不是推論):這顆檔案**在版本指紋的涵蓋範圍內**
|
||||
# (`daemon-version.py` 的 `scan_source()` 走 `git ls-files -co --exclude-standard`)
|
||||
# ⇒ 只是重編一次、一行原始碼都沒改,指紋就從 1325784830f592ff 變成 eba122f83317b67d
|
||||
# ⇒ 出貨線的 `daemon-source-check` 判「0.18.46 打包之後,1 個檔案變動過」而擋下。
|
||||
# **把編譯產物算進「原始碼指紋」,那個指紋就量不到它宣稱要量的東西。**
|
||||
#
|
||||
# ⚠️ 錨在本目錄(開頭的 `/`):不要寫成 `arcrun-app`,那會連子目錄裡同名的東西一起蓋掉。
|
||||
# 打包腳本要的產物在 `build/bin/`(早就 ignore),不是這一顆——三支腳本都沒引用它。
|
||||
/arcrun-app
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
{
|
||||
"_algo": 4,
|
||||
"version": "0.18.34",
|
||||
"fingerprint": "d8c7490b9d32e62e",
|
||||
"version": "0.18.48",
|
||||
"fingerprint": "a440a58a629f960c",
|
||||
"files": {
|
||||
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
|
||||
"CHANGELOG.md": "435942d764a283e1637b2ac7b6bc7304b9fc1e79b152f89780d1fa8e62440aa4",
|
||||
".gitignore": "c7a98c44ecc882a174720e43dabd4c04d7cf2503586426dcccaa213d5a27df38",
|
||||
"CHANGELOG.md": "51b85dc1e96ce190a8652df8a696bd664229b1c5bcb6cf695ced905dc5956d1b",
|
||||
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
|
||||
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
|
||||
"cardloop_test.go": "fdf09b62650c328f5d8075d964a0ca2dada5f8d9a271202768472998b45e31a8",
|
||||
"cardloop_vs_existing_wiki_test.go": "1fc42120114902571f61f65a56dc561bd2d858096c25303ce564a891a7a2511d",
|
||||
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
|
||||
"cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc",
|
||||
"cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0",
|
||||
"cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0",
|
||||
"cloud_audit.go": "e3dbf32785e3d6643a6d62c298c4ece2680d8f00c1148f6d55285dd670d9cf51",
|
||||
"cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a",
|
||||
"cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f",
|
||||
"cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa",
|
||||
"cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9",
|
||||
"cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6",
|
||||
"cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8",
|
||||
"cmd/arcrun-app/README.md": "9ea849308b5e578d9a43b73fc3d72637d21428eca9c96a448d31078e8627bcb4",
|
||||
"cmd/arcrun-app/app.go": "4c65f895c68f82a84d633e6e6d05fbabb86e51632f9ae3d693e39a67ca2b981c",
|
||||
"cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79",
|
||||
"cmd/arcrun-app/.gitignore": "91248524c8b00d155960f42ad6707ce36bd91ff44f66289eec45972d3915ad37",
|
||||
"cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9",
|
||||
"cmd/arcrun-app/app.go": "098e6a01aa0a298248ae9cc96275b209e5b8c9669325ba691c46383470216698",
|
||||
"cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96",
|
||||
"cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c",
|
||||
"cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2",
|
||||
"cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1",
|
||||
"cmd/arcrun-app/assets/store/Square310x310Logo.png": "f610d521767347974fcd68006055d06c5366e99c247d31a2b9221bae34d67798",
|
||||
@@ -48,9 +56,11 @@
|
||||
"cmd/arcrun-app/build/windows/wails.exe.manifest": "c63cb75ce48fb1354c95264f6fc43a7686a9b85a82a71efa9722ae4ce6561dc0",
|
||||
"cmd/arcrun-app/changelog-section.sh": "8b39dfdfbf8670fc850102e43b056b98dda52b2a7bd2a49f8d961c171c7abe23",
|
||||
"cmd/arcrun-app/check-cis.sh": "0627a2187160fd1f8c9f618588f6398c4b087a91394e8d387f9a9710f79dd8a5",
|
||||
"cmd/arcrun-app/check-launcher.mjs": "0c9b4ebd142554b69c1eedf46e5618078b60a050b9f7e5afbf6d3c34d509337d",
|
||||
"cmd/arcrun-app/check-remove-cleanup.sh": "b049881d1608b70fc0f27a130a1a794d780a522317d02ea8e17a866986c87c2c",
|
||||
"cmd/arcrun-app/check-render.sh": "8cd0fb4fe975111f74afb3bc841bc34fe9b160aeddcf67559a504c5187b8ae02",
|
||||
"cmd/arcrun-app/check-tray.sh": "7ff5c5e4d3dba6a356d9b556fc93cefe1c9e93827362ddd553fdb02717701cf5",
|
||||
"cmd/arcrun-app/connect.go": "18fc927ab55858596c1c6b9e913694fd60cc6dd0c12f4f1640144dcf7f12b1d9",
|
||||
"cmd/arcrun-app/connect.go": "0da69f6b781c3283851304511b1bd2ebd6f3b9663ae28b3605bab13072d39f9d",
|
||||
"cmd/arcrun-app/daemon-notes.mjs": "452434bf3d744366717219968ee94ba7bb773dbbcd62d69a46937cbb967811b9",
|
||||
"cmd/arcrun-app/daemon-version.py": "786a0212800dd5c5d3a9ff9faf5c5930be1428f033bc20a9de9399a30b18a824",
|
||||
"cmd/arcrun-app/daemon-version.test.mjs": "ee052f19806ba0fa3970442199a57dc76d69829c5b72f9e3879dc50ce0f1abfc",
|
||||
@@ -61,10 +71,12 @@
|
||||
"cmd/arcrun-app/diagnostics_export.go": "19328a384d653a24c557f5ee7497d62f1c1eaeba37976819b07e44f002b8862b",
|
||||
"cmd/arcrun-app/diagnostics_export_test.go": "00fbd5a436a82652d28f9974d29dcbaf57a432ec0df05bf491e6f9755bbff00b",
|
||||
"cmd/arcrun-app/diagnostics_stage_manual_test.go": "e1c9fe6b8d6d6e39c74efec5c8874bf4a7922cf5b03789e49c40ef24231d1f00",
|
||||
"cmd/arcrun-app/dist/Arcrun-v0.18.3.dmg": "bfe9282ca591b62390e53e439a9f97ed71e6c7c7ece2dd61e10b75e77b0ae991",
|
||||
"cmd/arcrun-app/dock_darwin.go": "25f07f57eea6dbacdc4eaf652ad56b83b4641ec6392f6a4a4943b67f3f24156a",
|
||||
"cmd/arcrun-app/dock_other.go": "b5f7bb020ec20c9ed27436b5be15aa16798a180d1bf200b4158c5bdbfe3e6be7",
|
||||
"cmd/arcrun-app/firstrun_test.go": "90d74db3e6a7b77617d50559a181229c0e720c20ead35b9554cb72e5212c7303",
|
||||
"cmd/arcrun-app/folder_badge.go": "ee2b3a62fc7424816268267e573be5b59174d1fcc3a492dded6b9b220506b1a5",
|
||||
"cmd/arcrun-app/folder_badge_fixture_test.go": "38c74b7571f60d24c3d1b337e79f38df153b589f3cd911cfbaf8369e4759ba18",
|
||||
"cmd/arcrun-app/folder_badge_test.go": "61377b6cbf51be32ee5b4fdc40724afd9a040e1041886c96f364d480bdd02801",
|
||||
"cmd/arcrun-app/freshinstall_e2e_test.go": "59d8c8c75365f7821f838baca9abd93f167bebd0998779a54c5fe33e390e7331",
|
||||
"cmd/arcrun-app/frontend/index.html": "3cbf1ec22539e0e0ad7e8e86b1857cf752e042ecfd52e816f7d2bdcd7cf99a91",
|
||||
"cmd/arcrun-app/frontend/package-lock.json": "a4020582cf3fb5f2413451d5d5498a54ceadae153d28e3f59f81f6a7b2c97caa",
|
||||
@@ -79,8 +91,8 @@
|
||||
"cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c",
|
||||
"cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55",
|
||||
"cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "75bec60d9af627c990b2f2007ffd70e28e33a4a728522099777e028cd670e385",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "08e890f3f0d7f603249175d5415f64c43370d08bbd30b66c7ccb39a58e399169",
|
||||
"cmd/arcrun-app/frontend/src/main.js": "327877a5bb79b79589e09b902e7002e33d275a413451ff90f230bb2e92418b52",
|
||||
"cmd/arcrun-app/frontend/src/style.css": "2cc59ab0be7668f2b8e2e42f655beb506a91f2d31414e0a40dbab33fffc8349a",
|
||||
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
|
||||
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
|
||||
"cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256",
|
||||
@@ -88,19 +100,20 @@
|
||||
"cmd/arcrun-app/manifest_field_test.go": "8d7d742d54c5ef003d34ecd12f8d1e444e4e55c5dd0f8f8625f5e9c20cdf5285",
|
||||
"cmd/arcrun-app/quit_test.go": "c7b46b466ef5243cbecfaa236cecdc0c3466892a7dfadc6280e7a9c22f843389",
|
||||
"cmd/arcrun-app/quota_card_test.go": "6cc0afdb3de1fd002419523f80be0b290970bc965d68f7c1c63fa641ba10b488",
|
||||
"cmd/arcrun-app/remove_folder_takedown_test.go": "46efb110a4f7afd89bab76072e17640e695772049391410f614a61110d8cd626",
|
||||
"cmd/arcrun-app/remove_folder_cleanup_test.go": "e2d9636f92560f2f30fc256e7b27f5002e681ec07c3f6b348b344a4cdc22dbe8",
|
||||
"cmd/arcrun-app/remove_folder_takedown_test.go": "6e30b13b9031e5d434bd1e38a0c8f66bc82ede5367123729529a8d35c982185f",
|
||||
"cmd/arcrun-app/selfupdate.go": "a1722cd1bfc90c8f068f3ee39f5aac43c7e9a5bf4a0144604482768e618065cb",
|
||||
"cmd/arcrun-app/selfupdate_test.go": "1c0752f3b3f4f262620e096558ded38ed21db94195b760a3f3b046ce9dbdd4ab",
|
||||
"cmd/arcrun-app/skipped_test.go": "f4c9de16c349c0b7f48ce62a6506364c87e3d4fbc99f415e3e6aec7d3da7f1aa",
|
||||
"cmd/arcrun-app/supervise.go": "f7eb6c74524b5d8d0abe520b831a98b85d19d18c00d1a585493ababcbd01c81f",
|
||||
"cmd/arcrun-app/tray_darwin.go": "ff3cb22fc2663b08b9f3cd14887ab4411ed338ec8920b58ad0fac029f09edae0",
|
||||
"cmd/arcrun-app/tray_darwin.m": "b4ddf0bb420adf00d580d65c8cfbbe130a78e803dd1e92a1617ddaf6ef172fac",
|
||||
"cmd/arcrun-app/tray_other.go": "0c3df95b5f7999f6319d0bf5e24fdbf821bf97a79db04077d9e7eaf358299880",
|
||||
"cmd/arcrun-app/tray_windows.go": "adb4f390d3523ac3676f16c27f2b5ea4b9cee850113bc767a45ab9ce503d4963",
|
||||
"cmd/arcrun-app/update_api.go": "1e94052085cea79d406a06772fd171944090a31c30af0456f57b214b065d4d87",
|
||||
"cmd/arcrun-app/upgrade_repair_test.go": "65413d0b07b6c011de2af2783247d0f602a680a199229b52a77e176626c8ef26",
|
||||
"cmd/arcrun-app/wails.json": "a6448229972e3194026f6a407a427a5601ce6f5deb43d1fb7c5b5226512d8bc5",
|
||||
"cmd/collector/main.go": "fd04291f6cd4833f00593a0580baeaa231875da6d26636fca4f51090f0a80c80",
|
||||
"collector": "ef769ff086a4da691b4bd646de4efb6957204efa829545e43cbcb99bf9503b70",
|
||||
"convert.go": "fb3b52354b94f527ccffa5600620749a5fa6009834530aac04b7d141bb3e3b4b",
|
||||
"convert_docx.go": "5d4f459ff866c86ee78424503e1c9a410e1dbfd0ece40d63a603b94b61b7ddaf",
|
||||
"convert_pdf.go": "e4955008f9bf9e458304fa0baa087ae7a37ac52e9fb66df3e99f22a4c513b9b1",
|
||||
@@ -110,50 +123,57 @@
|
||||
"convert_table.go": "5313b96e4d85d65e6c3c8b51e7c0e25aee11a47fdbda534fe8432fbea87836c5",
|
||||
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
|
||||
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
|
||||
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
|
||||
"direct.go": "98d51b233e31320de20f4c605ce420433fb4720136c9eb188fbd69e1a136fb69",
|
||||
"direct_extract_test.go": "d3465c1b5953858ae780d192a2cf515d452f48eee3ff18a6345ce64320debbbb",
|
||||
"direct_multi_test.go": "d0aa211d90aaaa9d7dbe2b04f1aaa5989e91e91b037144cc0f4e0fd068f43c2b",
|
||||
"direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3",
|
||||
"direct_pacing_test.go": "c21298922866eb9dabc6a87d9d466e08e1001db52841c86f3e720dacfe955bd8",
|
||||
"convert_wiring_test.go": "3389817e596341369a3fc102846bf5b081cee668e86ebf4830eb633978de652b",
|
||||
"direct.go": "ba19086aa177884405c038a8841cd5b6b2ef2bf2043d18a708ff7e6c6c9ca45f",
|
||||
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
|
||||
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
|
||||
"direct_pacing.go": "b54838ce85f72ecd2d3388fa7cf89050c87fb10bd67dca07cd18d49568a8837c",
|
||||
"direct_pacing_test.go": "f118ec03ab0bf51871037aa51eb442b98074e218430d244cb7d54b1d9791d738",
|
||||
"direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080",
|
||||
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
|
||||
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
|
||||
"direct_t182_test.go": "1c64a9d0f41b98504e10a20a96f28e8a47cced4923171ef40dc800b861d0275b",
|
||||
"extract.go": "76c40068d378d6036e330bb56e41caec2f80b9b219352586744194df560c8147",
|
||||
"extract_gemma.go": "9d127fa467b8c760ec46180123ec0fdadd4336290555687f16baf0b80b24c6f2",
|
||||
"extract_gemma_test.go": "1fae16cc43e3de9728b88ea7151da9c836bf32cb5bead2805fbadc5e788fbab8",
|
||||
"extract.go": "b6b5212ab5107c53da17bf677ac54b040f2f78674b902c8b5750a3befa4a1f23",
|
||||
"extract_gemma.go": "53483c6ea3992759abbae45562bfc418d4df1728b44fa87b3bd30324d28fc4f4",
|
||||
"extract_gemma_test.go": "439f12c1c6fb534b31f983305a4d7898ef66dfcbe697714be08a6b571e4e57c0",
|
||||
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
|
||||
"extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3",
|
||||
"extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e",
|
||||
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
|
||||
"extract_workersai.go": "8300691e1a41df4096d316686efbd0016e8df4d482be55b8ce4388b53bd9e1c5",
|
||||
"extract_workersai_test.go": "8850e7128f03ecb4754a4663281800dc73d556fab1d80367924b58bae3dd8ec3",
|
||||
"folder_progress_wiring_test.go": "c5a4575395f1cee5d1c0ee58728a034113534a3f0d456e4e0d23563da840d314",
|
||||
"folderindex.go": "575a56fc7763ad82aef14fc5e0e1df39d351f0903babe6251bf2df990e358089",
|
||||
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
|
||||
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
|
||||
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
|
||||
"foldertree.go": "c12461137d6c177db8e1e2f5c7446bcbd6bfd567f044cc07e11e883cc71892dd",
|
||||
"foldertree_live_test.go": "ace96ffdf7670a69761973e810fb6548c7036d2768141e09db515313df1e9a50",
|
||||
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
|
||||
"foldertree_publish_test.go": "104b26ffac1fc78a4c9b061aba3e2167c7d71ea469073537c3d86e3896debdea",
|
||||
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
|
||||
"foldertree_traversal_test.go": "b9a3ce5c784b7601e628b1299596b3572ca787e654cd2ee20b973d053bab84aa",
|
||||
"go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db",
|
||||
"go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3",
|
||||
"grounding.go": "7152771ab1bb152946209f8e94e52bdf2facc6192b7a8ca018e37fed4557e9c0",
|
||||
"grounding_test.go": "46f5fd98297caf45b5db494f69363f2544e1cfa5634ce24fe25ca6a87ae296d2",
|
||||
"ignorerules.go": "e6923d0fe35e377e75cfe2364a10624ca40aa6d28efe48ea725036769a48ea14",
|
||||
"ignorerules_test.go": "19675a03539e92e7fd58a46ce8105875cc5aaa42f53615348de775b6d90dbf16",
|
||||
"ingestplan.go": "0a0f170bb17db210231039e9c67f7f796a2eacd4450c54f5325d3483439d4ecb",
|
||||
"ingestplan_test.go": "1ad31f4fb69d917e3f9d46f5615f38074b7ed6ec8baa01d71bd1600d317b7878",
|
||||
"ingestplan.go": "f32cd7a740fc291a6b54e2804e0bdf0d50770962414fafe92b015ddf0c6fcddf",
|
||||
"ingestplan_test.go": "baf57a5edc99482842c16d1ea28d1ad6afe7a236a56fc42dac8d007341b578be",
|
||||
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
|
||||
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
|
||||
"inventory_test.go": "1821c81cf2b9e20fb82a79313686393391a6f46185dfa4a4a4c2bfb4e0e7c170",
|
||||
"inventory.go": "707b2b74dccbd082d56793a558bb28ce0f8812d458b221d8b2d6b084b752af50",
|
||||
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
|
||||
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
|
||||
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
|
||||
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
|
||||
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
|
||||
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
|
||||
"main.go": "e02e7b32dd230f33e35c7ebf62d81b7599e2b86a0ef7cadc0fd34b672f42381d",
|
||||
"manifest.go": "768049a34c06920711a980bb19fb41e7f1feff648f22265ba0c3d1c1ad85020f",
|
||||
"machinemark.go": "592a39597fd011e8b1603318eaedf452aa58fca0639244a80218aa9a0ac6dbf5",
|
||||
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
|
||||
"manifest.go": "c6b6fbdd5cc6963345048aa1b0a32f61b7b0b5b84616a15827aea290bd3758e1",
|
||||
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
|
||||
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
|
||||
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
|
||||
"probe_real_manual_test.go": "6170cd7d41b6e099ad93e229f55e5f9d64d509412969f9bf162788ae8e787aef",
|
||||
"probe_workersai.go": "4667c7edeb75a7f69d99bdb9141c8d55325d3b99aecc9a661a4080c91ef517fd",
|
||||
"progress.go": "c2c86ecfea5a8227eb40018c55d48f26c4f2de1b8775fde9acd231cba8b7bb37",
|
||||
"probe_workersai.go": "2d12d7e83f13a83f7dca0f4f6ea1ff0041db70b4f47d86f940c2fac80cab6980",
|
||||
"progress.go": "98380d3c374570f3182200484b9b2b6a093d41531433b80eb78a92cf94dec69a",
|
||||
"progress_test.go": "21258889ad7680da2d6cf8812accda7e6a1aca6c6fd604394ec4363c2ed902bb",
|
||||
"progress_wiring_test.go": "d1d144269539825ef4a4d9ffca5c49b021cb27cecea7d280309f3f935460b3cd",
|
||||
"quota.go": "034a2a682b66606e9ab531a251e5157109c9a66ddca6e3ffcecc8fcd10dc0aa3",
|
||||
@@ -162,22 +182,27 @@
|
||||
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
|
||||
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
|
||||
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
|
||||
"scan.go": "23993b1411af0a0308aa6614e514d8c60bcbeed6ed93087b9c5bcc2e0f4763e9",
|
||||
"scan.go": "546329b883228687a60d48419c19113a6208f0f5ea18a0d3ba0fc401ab3c0974",
|
||||
"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_live_test.go": "2a7f5d6fada420a7f55fa52b87361842755c68c1cb58f8c3fd0153341f709a0a",
|
||||
"sourcerepair_test.go": "2a1ddddaad8ac7adc36350859e2d742c0fea12b99370a4d4831d390db1cc6720",
|
||||
"stallguard.go": "13be8ae905492054d516f466856bc6949b16e1af6ea32068f94b21635799bdc6",
|
||||
"stallguard_test.go": "678b82a8448d1394a0a85d4af1f133c980f5ff0dcb54953b0fcf1671d186147f",
|
||||
"supervisor/hidewindow_other.go": "291b4ecb0783ff3e813c68b5464161d9af2f84d31c773c7f1f710f218bb5d610",
|
||||
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
|
||||
"supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388",
|
||||
"supervisor/supervisor.go": "f79b32e92e67058a056dc749a6182573eefcbbc9bc7b2cd5a997f4e8066a004e",
|
||||
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
|
||||
"sync_status.go": "5f35c17a01e07146b155081714200e07eb54e5e0de191be0b2208b9e13e92daa",
|
||||
"sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3",
|
||||
"sync_status.go": "799d3481c361647c9cf1ae40ac4c550d6690a87f843c514f8b830d811e7bba1f",
|
||||
"sync_status_test.go": "73bbc83c9e8fc18575895a7b7b5feab7a76d41f05a74d74bbbdbf98b045ccfa2",
|
||||
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
|
||||
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
|
||||
"template_install_test.go": "e2d096a80c52ab10fd1a9e26cab2a9621cc2017df8c0c5726aaa450a00375566",
|
||||
"template_not_knowledge_test.go": "d25e84a13fb97351cd3a579178a5e24cb266309bdc9d526ff7bec442071df3ab",
|
||||
"template_not_knowledge_test.go": "59c272068d308defd1fb19aedfc5b311bb64d2fc58e958e78b98d9f7ba27b6f1",
|
||||
"templatefs/.claude/VERSION": "04d659518a1793d5396007fcabdc9211a8c1db8406bf4e0fa8069171fdb06518",
|
||||
"templatefs/.claude/commands/issue-handle.md": "2010f4e418dbb51e928873019c30602b79ff4dbf79509cb412884616f10a0e01",
|
||||
"templatefs/.claude/commands/rag-extract-file.md": "33e8f10fbde076452a5a304cb153982603b238ffe1805e0faf7b7fbed246298e",
|
||||
@@ -215,6 +240,7 @@
|
||||
"templatefs/system-dev/wiki/status.md": "ef03d670e48847c94954db93469bcb9d0bf38860a155fb0847c3e16d3411b839",
|
||||
"templatefs/system-dev/workflows/tasks-project-sync.local.sh": "7a611abc8b597d7c047e1b73c4a19c011ba1719f6688351b677b4feee0a4d4fe",
|
||||
"templatefs/system-dev/workflows/tasks-project-sync.yaml": "e8c7dd78d338e64219ad0eb7aae27aec02b6ac2ad962f309145055a87f1cd5db",
|
||||
"templateuntouched.go": "74e6c0dda22228ac65cc841ce83460d81026134d41b4b16c528bf7ce760db26f",
|
||||
"testdata/grounding/card-access-paraphrase.md": "2bbf4427b1ea2c5b73995b135752f60c4978f2c9121647c4a0aa7b42d6063570",
|
||||
"testdata/grounding/card-delivery-paraphrase.md": "727f6d09cb4ebb0e8248988bb9872573d4339f2256e6723a588eceb04aa4fd42",
|
||||
"testdata/grounding/card-geek6688-faithful.md": "7b7200aa2d142720cde51c45b7266e32a5b9f278a808a115c2546443903f09f8",
|
||||
@@ -222,19 +248,23 @@
|
||||
"testdata/grounding/source-access-en.md": "a1c28715a06940b0bb512cd2220aa6431bf47c1ae84da314f4a076ed6d21c906",
|
||||
"testdata/grounding/source-delivery-zh.md": "80732bee124312b791609fe499b274f81cdaa12916b30fc21bc309c41ad1ac86",
|
||||
"testdata/grounding/source-requirements.md": "5fd1115db5009aee98667634a9a374ee66460a2cb53c3819065080f2f47506f3",
|
||||
"tidy.go": "e0b104866a11d0ce25ac80ca536a644900907d1ec6231f95e132843d88be5d0a",
|
||||
"tidy.go": "aee6d6caebe45721d714f2954a45e453a3521b44d7edafe1b7bd15ece6d98a47",
|
||||
"tidy_test.go": "80fd37d7abf9f9fd075006da42aea73a3843c49f969e62623687519102dfe3ea",
|
||||
"tidy_wiki_untouched_test.go": "4a42be32c770abdf9d29ee3489a1421447cac82557a4dafcf2f23e1cadc1d0c5",
|
||||
"trigger.go": "f689f701bef08401f5d47f5b3d783d24a88fc9a9426fe41bdbbb3f9b152ab405",
|
||||
"trigger_test.go": "0c2482d18cea0568cd2a0f5eae5b02a0984b9b2bc83461eb882c630d6381ca02",
|
||||
"triggeroutcome.go": "25832326ecb3694d8a3a0f8a115ce8df6327b70ff61b0bec1357d57e53e81836",
|
||||
"triggeroutcome_test.go": "0f736ba4125cf3da8e736791400a8091d0372fc0a4d252c4a1b293a4483d7720",
|
||||
"upload.go": "a41bb459eac8f6c114be567a2d7c53bbdb11b0e31f0bda4b78776502664273cb",
|
||||
"upload_test.go": "f97d2f3bf2213e6fff94b77b7120aa4e4f23f58c7e413c0416f02be387f2cb91",
|
||||
"upstream_error_visible_test.go": "6b72c903594b3eba47cfdeb40654f1f5c341757f79dfbc5c05351cd5b99c9368",
|
||||
"vault.go": "91d61613d37da5c16b24e08c70d59cf4c9cde9375737573d65921949087f1465",
|
||||
"vault_footprint_test.go": "e3909a160675f4d750c4ad0ed608c0e8872e38bc5c125b6389c6b1e5f1b4afdb",
|
||||
"vault_footprint_test.go": "fcfc18b90b038f16fd12c5533aff66acf40ee44b28fd4972880d8ebed807e3b1",
|
||||
"vault_subdir_test.go": "ecdd3221342e6b922ae29a7f873c4c599bd22bfb716b0a5044ba2f11a1e08d32",
|
||||
"vault_test.go": "f7ba916d0ec4fc69ea7cc3832ebca0aee83e00f4ae93077038519d06817b4c21",
|
||||
"verify-60.sh": "323347b19a366d5bcf8ddb65c9800dde32027d0170675f90e0ea46d6d1fcadc4",
|
||||
"wikishape.go": "b6e55f1d006c4eb4bec37448d82d56e70ad7f577d7f97dc5bb00480cc1de7f23",
|
||||
"wikishape_test.go": "910d019d13302e8cab0978fd15d201f96db2a533751779f2707f6d90ac3e148e"
|
||||
"wikishape.go": "75901acc73abaa9b9340f13c110d90e1972207326acf8fc9ea9d6912b88524c1",
|
||||
"wikishape_origin_test.go": "83310bb8476fb2af0eee1d7924cfc02695f280ffeeae63e5fbd7c70d4a8b0543",
|
||||
"wikishape_test.go": "1727392a92f16b9e0b7666d752cbdd49bdf37edf310cb841cbb90f8b1a4939a5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,19 @@
|
||||
"0.18.31": "d6b2b6083258b853",
|
||||
"0.18.32": "4753db807169fe3d",
|
||||
"0.18.33": "7ba0f6dd2d080925",
|
||||
"0.18.34": "d8c7490b9d32e62e"
|
||||
"0.18.34": "d8c7490b9d32e62e",
|
||||
"0.18.35": "3bd7f3fb0124bda4",
|
||||
"0.18.36": "abb2f2cc2498f071",
|
||||
"0.18.37": "3bb5c94f68a26a06",
|
||||
"0.18.38": "7add5da65c671548",
|
||||
"0.18.39": "316426f50e886003",
|
||||
"0.18.40": "a4485965c128ef9d",
|
||||
"0.18.41": "8033de0cdec3537b",
|
||||
"0.18.42": "43e0c6ac08233f07",
|
||||
"0.18.43": "34f17e1e6a190eaa",
|
||||
"0.18.44": "19e26497ae609682",
|
||||
"0.18.45": "dc05513103f93306",
|
||||
"0.18.46": "1325784830f592ff",
|
||||
"0.18.47": "0decb0810da46a3f",
|
||||
"0.18.48": "a440a58a629f960c"
|
||||
}
|
||||
|
||||
+77
-12
@@ -1,19 +1,84 @@
|
||||
# README
|
||||
# Arcrun 桌面小幫手(Wails)— 地端 build 說明
|
||||
|
||||
## About
|
||||
> 這份取代了原本 `wails init` 留下的英文樣板 README(那份只寫「用 `wails build`」,
|
||||
> 而這裡真正的打包線是旁邊那四支腳本,不是裸的 `wails build`)。
|
||||
>
|
||||
> 🔴 **為什麼會有這份**(leo 2026-08-24,`inkstone/arcrun-rag#137`):
|
||||
> 「daemon 部分要傳給地端做,**或是你做好給它 build**」——採後者。
|
||||
> 雲端只交**編得起來的 code**,安裝檔(DMG/exe/msix)由地端打,
|
||||
> 因為那需要 macOS/Windows 環境與簽章,雲端沒有。
|
||||
|
||||
This is the official Wails Vanilla template.
|
||||
---
|
||||
|
||||
You can configure the project by editing `wails.json`. More information about the project settings can be found
|
||||
here: https://wails.io/docs/reference/project-config
|
||||
## 一、一次性前置(每台機器裝一次)
|
||||
|
||||
## Live Development
|
||||
| 需要 | 怎麼裝 | 用來做什麼 |
|
||||
|---|---|---|
|
||||
| Go **1.25+** | `brew install go`(`go.mod` 寫 `go 1.25.0`;較舊的 Go 會自己抓 toolchain) | 編 App 與 collector |
|
||||
| Node 18+ | `brew install node` | 編前端(Vite) |
|
||||
| Wails CLI v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0` | 打包 |
|
||||
| mingw-w64 | `brew install mingw-w64` | **只有要打 Windows 版才需要**(WebView2 要 CGo 交叉編譯) |
|
||||
| cmake、icu4c | `brew install cmake icu4c` + `bash build-msix.sh --setup` | **只有要送 Microsoft Store 才需要** |
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
> `wails` 裝完記得 `export PATH="$PATH:$(go env GOPATH)/bin"`(四支 build 腳本自己也會補這行)。
|
||||
|
||||
## Building
|
||||
## 二、打包:跑既有的腳本,不要自己下 `wails build`
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
```sh
|
||||
cd collector/cmd/arcrun-app
|
||||
|
||||
./build-mac.sh # → build/bin/Arcrun.app
|
||||
./build-dmg.sh # → dist/Arcrun-<版本>.dmg (自己會先跑 build-mac.sh)
|
||||
./build-win.sh # → dist/Arcrun-<版本>.exe (在 Mac 上交叉編譯,需 mingw-w64)
|
||||
./build-msix.sh # → dist/Arcrun-<版本>.msix (送 Store 用;不必自購憑證)
|
||||
```
|
||||
|
||||
🔴 **版本號不要手打。** `daemon-version.py --stamp` 會從 `collector/CHANGELOG.md`
|
||||
最上面那個「下一版(未發佈)」段落戳出版號並補上日期——四支腳本都已經呼叫它。
|
||||
本次(`#137`)的 changelog 段落已經寫好,所以**這一版打出來會是 `0.18.35`**
|
||||
(`./daemon-version.py` 唯讀查詢可以先確認)。
|
||||
|
||||
🔴 打包前若改過任何 `collector/` 底下的檔,指紋會變 ⇒ 同一個版號不准對應兩份原始碼,
|
||||
腳本會擋下來並告訴你怎麼辦。那是刻意的閘,不要繞過它。
|
||||
|
||||
## 三、交貨前自己先驗(這三支都在本目錄,跑得很快)
|
||||
|
||||
```sh
|
||||
go test ./... # 後端(含本次新增的 App 啟動器 12 條)
|
||||
bash check-cis.sh # CIS 合規(色票/lockup/選中態)
|
||||
npm --prefix frontend run build && node check-launcher.mjs # 畫面與互動(需 playwright)
|
||||
```
|
||||
|
||||
`check-launcher.mjs` 會真的把 `frontend/dist` 渲染出來、真的點下去,
|
||||
截圖丟在 `/tmp/arcrun-launcher-shots/`。沒裝 playwright 它會自己跳過
|
||||
(`npm i -D playwright && npx playwright install chromium`)。
|
||||
|
||||
macOS 上另外還有 `check-render.sh`(量 lockup 像素與深色模式),需要本機的 Chrome。
|
||||
|
||||
## 四、只有在真機上才驗得到的(雲端做不到,地端請補)
|
||||
|
||||
雲端這台沒有 GUI、也沒有 WebView,所以下面這些**沒有被驗過**,
|
||||
打包完請在真機上走一次:
|
||||
|
||||
1. `wails dev`(或直接開打好的 `Arcrun.app`)能不能起來、視窗尺寸對不對。
|
||||
2. 系統匣:Mac 是原生 `NSStatusItem`(`tray_darwin.m`)、Windows 是 `energye/systray`;
|
||||
點 icon 開窗、右鍵只有「結束 Arcrun」。
|
||||
3. **App 啟動器**(本次新增):打開就落在「App 界面」,九宮格列出的是你連的那個
|
||||
知識庫實際裝了的 App;點一個進去、按一下它的動作,看結果回不回得來。
|
||||
4. Windows:WebView2 有沒有裝、SmartScreen 會不會攔、Defender 會不會誤判。
|
||||
|
||||
## 五、平台檔案對照(改東西前先看這張)
|
||||
|
||||
| 檔 | build tag | 說明 |
|
||||
|---|---|---|
|
||||
| `tray_darwin.go` / `tray_darwin.m` | `darwin` | macOS 原生 NSStatusItem |
|
||||
| `tray_windows.go` | `windows` | Windows systray |
|
||||
| `tray_other.go` | `!darwin && !windows` | **no-op**。讓 `go build`/`go test` 在 Linux/CI 上跑得起來,對出貨零影響(Linux 不是出貨平台) |
|
||||
| `dock_darwin.go` / `dock_other.go` | 同上 | Dock icon 隱藏 |
|
||||
|
||||
## 六、開發時的即時預覽
|
||||
|
||||
```sh
|
||||
wails dev
|
||||
```
|
||||
Vite 熱重載;`http://localhost:34115` 可以用瀏覽器連進去,Go 方法一樣叫得到。
|
||||
|
||||
+161
-3
@@ -68,6 +68,15 @@ type accountCfg struct {
|
||||
RetiringFolders []string `json:"retiring_folders,omitempty"`
|
||||
Extractor string `json:"extractor,omitempty"`
|
||||
GeminiAPIKey string `json:"gemini_api_key,omitempty"`
|
||||
// PortalSession/PortalSessionExp=這台電腦對這個知識庫的 portal 登入憑證
|
||||
// (arcrun-rag#137 App 啟動器用;App 詳情與動作只有 portal session 那條路有,
|
||||
// 見 apps.go 檔頭)。**這是 session token 不是密碼**——密碼仍然零落地。
|
||||
//
|
||||
// 🔴 這兩欄是 App 端獨有的(collector 的 AccountConfig 沒有):collector 讀 config
|
||||
// 時會忽略不認得的欄位,所以加在這裡是安全的;反過來(collector 有而這裡沒有)
|
||||
// 才是上面那條註解講的、會靜默掉欄位的方向。
|
||||
PortalSession string `json:"portal_session,omitempty"`
|
||||
PortalSessionExp int64 `json:"portal_session_exp,omitempty"`
|
||||
}
|
||||
|
||||
type directConfig struct {
|
||||
@@ -110,6 +119,13 @@ type syncStatus struct {
|
||||
// arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。
|
||||
// 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。
|
||||
Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"`
|
||||
// arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
|
||||
// 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。
|
||||
Resync map[string]collector.ResyncStatus `json:"resync,omitempty"`
|
||||
// arcrun-rag#159:逐個看守資料夾的同步現況(key=資料夾路徑)。
|
||||
// 同上:形狀定義在 collector/progress.go/sync_status.go,這裡原樣接住。
|
||||
// 這是資料夾那一列打不打勾的**唯一**依據(見 folder_badge.go)。
|
||||
FolderProgress map[string]collector.SyncProgress `json:"folder_progress,omitempty"`
|
||||
}
|
||||
|
||||
type skippedDoc struct {
|
||||
@@ -232,6 +248,23 @@ type UIFolder struct {
|
||||
Retiring bool `json:"retiring,omitempty"`
|
||||
RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆
|
||||
RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫)
|
||||
// arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。
|
||||
// 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到,
|
||||
// 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。
|
||||
//
|
||||
// ⚠️ 2026-08-28(arcrun-rag#159)**這一行不再畫在畫面上**——leo:「補送中是什麼
|
||||
// 意思?不要發明奇怪狀態」。同步狀態改由下面 Sync/SyncTip 那一格的圖示表達,
|
||||
// 而它算的是真的份數(見 folder_badge.go)。這個欄位留著給診斷檔與 status.json,
|
||||
// #140 的「不能靜悄悄地重送」由圖示接手:補送中的檔在 manifest 裡就是還沒送成功,
|
||||
// 圖示本來就不會打勾。
|
||||
ResyncNote string `json:"resyncNote,omitempty"`
|
||||
|
||||
// arcrun-rag#159:這一列的同步狀態圖示。Sync 是機器代碼(ok/working/trouble/
|
||||
// unknown),SyncTip 是滑過去看得到的一句短話。判準與文案都在 folder_badge.go
|
||||
// ——**寫在 Go 這一側**,因為「打勾是不是真的」要能被測試守著,
|
||||
// 而前端只負責把代碼換成一個圖示。
|
||||
Sync string `json:"sync"`
|
||||
SyncTip string `json:"syncTip"`
|
||||
}
|
||||
type UIAccount struct {
|
||||
Name string `json:"name"`
|
||||
@@ -241,7 +274,11 @@ type UIAccount struct {
|
||||
// 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新,
|
||||
// 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套
|
||||
// (collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。
|
||||
CloudVerKnown bool `json:"cloudVerKnown"` // false=查不到(連不上/還沒查過),前端要老實說「查不到」
|
||||
CloudVerKnown bool `json:"cloudVerKnown"` // false=**從來沒查到過**這台的版本(不是「這一輪連不上」,見 direct.go #159)
|
||||
// arcrun-rag#159:這一輪 /health 通不通。與 CloudVerKnown 是兩件事——
|
||||
// 版本是事實(查到過就一直知道),可達性是當下狀態(會抽風)。
|
||||
// 前端拿它把版本號調淡並在 tooltip 說明,**不拿它把版本抹掉**。
|
||||
CloudVerFresh bool `json:"cloudVerFresh"`
|
||||
CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新
|
||||
CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值)
|
||||
CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版
|
||||
@@ -496,7 +533,17 @@ func (a *App) GetState() UIState {
|
||||
for i, acc := range cfg.Accounts {
|
||||
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email}
|
||||
for _, f := range acc.WatchFolders {
|
||||
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
|
||||
uf := UIFolder{Path: f, AccIdx: i}
|
||||
// arcrun-rag#140:雲端補送中就把那句人話帶到畫面上。
|
||||
// key=資料夾路徑,與 collector 寫入 status.json 時同一把(見 direct.go 的 resync[root])。
|
||||
if rs, ok := sync.Resync[f]; ok {
|
||||
uf.ResyncNote = rs.Note
|
||||
}
|
||||
// #159:這一列打不打勾。key 與 collector 寫入時同一把(資料夾絕對路徑)。
|
||||
// 查不到=collector 還沒回報過這個資料夾 ⇒ folderBadge 回 unknown,不猜。
|
||||
fp, known := sync.FolderProgress[f]
|
||||
uf.Sync, uf.SyncTip = folderBadge(fp, known)
|
||||
ui.Folders = append(ui.Folders, uf)
|
||||
}
|
||||
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
|
||||
// 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。
|
||||
@@ -511,6 +558,7 @@ func (a *App) GetState() UIState {
|
||||
// t215:per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost),
|
||||
// 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。
|
||||
if accSt, ok := sync.AccountDetails[ui.Host]; ok {
|
||||
ui.CloudVerFresh = accSt.CloudCheckOK
|
||||
ui.CloudVerKnown = accSt.CloudUpdateKnown
|
||||
ui.CloudVerStale = accSt.CloudUpdateStale
|
||||
ui.CloudVerMine = accSt.CloudVersion
|
||||
@@ -719,7 +767,18 @@ func (a *App) AddFolder(accIdx int, path string) error {
|
||||
// vs 我不要這份資料了),而**猜錯任何一邊都是不可逆的**——猜「保留」則產品承諾的
|
||||
// 「資料所有權完全屬於使用者」是假的;猜「收回」則整理好的知識被誤刪。
|
||||
// ⇒ 在動作的當下把兩個後果講清楚、讓他自己挑(見前端 confirmRemove 的文案)。
|
||||
func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error {
|
||||
//
|
||||
// cleanupLocal=第三個獨立選擇(arcrun-rag#138,leo 2026-08-24:「碎型會在每個資料夾
|
||||
// 安裝隱藏資料夾,人工刪除不容易,所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的
|
||||
// 資料夾刪掉」):連同硬碟上那些隱藏資料夾一起收掉。
|
||||
//
|
||||
// 與 takedown **正交,不合成一個開關**:takedown 管**雲端**的知識(搜尋還找不找得到),
|
||||
// cleanupLocal 管**他硬碟上**的產物(資料夾乾不乾淨)。兩個後果不一樣,合起來就是替他決定
|
||||
// ——這正是 #46 那次修掉的病,別再犯一次。
|
||||
//
|
||||
// 🔴 順序不可顛倒:**先把資料夾從 WatchFolders 拿掉並重啟看守,再刪本地產物。**
|
||||
// 反過來的話 collector 還看著那個根,剛刪掉的 `.wiki/` 下一輪就長回來。
|
||||
func (a *App) RemoveFolder(accIdx int, path string, takedown, cleanupLocal bool) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -753,6 +812,17 @@ func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error {
|
||||
return err
|
||||
}
|
||||
restartWatch()
|
||||
if cleanupLocal && found {
|
||||
// 看守已經停掉了(restartWatch 讀的是剛存下去的設定),現在刪才不會被重建。
|
||||
// 刪不掉的那幾筆不讓整個移除失敗——資料夾已經從清單拿掉了,那是使用者要的主要結果;
|
||||
// 清理是附帶的善後,回一個講得出「哪幾筆沒刪掉、為什麼」的錯誤就好。
|
||||
if _, res, cerr := collector.ApplyCleanup(path, cfg.otherWatchedRoots(accIdx, path)); cerr != nil {
|
||||
return fmt.Errorf("資料夾已經移除,但清理 Arcrun RAG 建立的檔案時出錯:%w", cerr)
|
||||
} else if len(res.Failed) > 0 {
|
||||
return fmt.Errorf("資料夾已經移除,但有 %d 個項目刪不掉(第一個:%s——%s)",
|
||||
len(res.Failed), res.Failed[0].Rel, res.Failed[0].Error)
|
||||
}
|
||||
}
|
||||
if takedown {
|
||||
// 不必等下一輪輪詢——使用者剛按下按鈕,他期待「現在就開始」。
|
||||
// 沿用既有的 sync-now 訊號檔,不新發明一套 IPC。
|
||||
@@ -761,6 +831,43 @@ func (a *App) RemoveFolder(accIdx int, path string, takedown bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlanFolderCleanup 回答「按下『連同這個資料夾裡的檔案一起清掉』會刪到哪些東西」。
|
||||
//
|
||||
// 🔴 #138 的驗收條件之一:「使用者要能在動手前看到將要刪掉哪些東西」——不是按下去就無聲刪光。
|
||||
// 這支**只讀不寫**(collector.PlanCleanup 保證),前端在確認對話框裡先叫它、把清單攤出來。
|
||||
// 真的動手時 ApplyCleanup 會**重算一次**,所以這裡拿到的清單過期也不會刪錯東西。
|
||||
func (a *App) PlanFolderCleanup(accIdx int, path string) (*collector.CleanupPlan, error) {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if accIdx < 0 || accIdx >= len(cfg.Accounts) {
|
||||
return nil, fmt.Errorf("找不到這個知識庫帳號")
|
||||
}
|
||||
return collector.PlanCleanup(path, cfg.otherWatchedRoots(accIdx, path))
|
||||
}
|
||||
|
||||
// otherWatchedRoots=除了 path 以外、**所有帳號**還在看守(或還在收回中)的資料夾。
|
||||
//
|
||||
// 🔴 為什麼要跨帳號收集:巢狀擺法是真的(2026-08-24 現場,`pms` 與 `pms/pms_v1_legacy`
|
||||
// 同時在同一個帳號的看守清單裡)。移除外層時把內層的工作區刪掉=把一個還在跑的同步弄壞,
|
||||
// 而且不同帳號可以看守同一棵樹底下的不同層——判準是「這條路徑還有沒有人在用」,
|
||||
// 跟它掛在哪個帳號無關。
|
||||
func (c *directConfig) otherWatchedRoots(accIdx int, path string) []string {
|
||||
var out []string
|
||||
for i := range c.Accounts {
|
||||
for _, f := range c.Accounts[i].WatchFolders {
|
||||
if i == accIdx && f == path {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
// 還在收回中的資料夾同樣還沒結束——它的帳本正在被逐筆撤除,別去動它的檔。
|
||||
out = append(out, c.Accounts[i].RetiringFolders...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pruneFinishedRetirements 把「collector 已回報收乾淨」的資料夾從設定裡清掉。
|
||||
//
|
||||
// 為什麼由 App 清而不是 collector 自己清:config.json 的寫入者只有 App 一個,
|
||||
@@ -846,6 +953,57 @@ func (a *App) OpenLogFolder() error {
|
||||
// LogFolderPath 給畫面顯示用(讓使用者就算按鈕失效也知道去哪找)。
|
||||
func (a *App) LogFolderPath() string { return appDir() }
|
||||
|
||||
// ── 資料夾結構(`inkstone/InkStoneCo#44`,leo 2026-08-26)──
|
||||
//
|
||||
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
|
||||
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
|
||||
// 沒同步的那幾份為什麼沒上去。」——這支是桌面那半的資料入口。
|
||||
//
|
||||
// 🔴 **三件刻意不做的事**,每一件都是紅線:
|
||||
//
|
||||
// ① 不自己走一次檔案系統。樹是 collector 每輪算好落地的(BuildFolderTree ⇒
|
||||
// folder-trees.json),這裡只讀。App 若自己數一次,就會出現「畫面說 A、
|
||||
// 雲端說 B」——同一個資料夾兩份實作,遲早對不起來。
|
||||
// ② 不去雲端拿。樹本來就是本機算的;繞一趟雲端只是把離線、額度用完、
|
||||
// 退避窗口這幾種情況都變成「畫面壞掉」。
|
||||
// ③ 不掛在 GetState 上。GetState 每秒被 tick 呼叫一次,而樹上限 300 個節點;
|
||||
// 只有使用者真的按開那個資料夾時才需要它 ⇒ 獨立一支、按需呼叫。
|
||||
//
|
||||
// 回傳 nil 代表「這個資料夾還沒被回報過」(剛加入、還沒跑完第一輪),
|
||||
// **不是**空樹——畫面要分得出「還沒掃到」與「掃過但裡面是空的」(arcrun-rag#106
|
||||
// 那條紅線的同一個形狀:不准拿一個我們自己編的 0 去回答使用者)。
|
||||
func (a *App) GetFolderTree(path string) (*collector.FolderTree, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 快照與 manifest 同目錄。manifest 路徑以設定檔為準(使用者可能把它指到別處),
|
||||
// 讀不到設定就退回預設位置——與 statusPath() 同一個假設。
|
||||
store := filepath.Join(appDir(), "folder-trees.json")
|
||||
if cfg, err := loadCfg(); err == nil && strings.TrimSpace(cfg.Manifest) != "" {
|
||||
store = collector.FolderTreeStorePath(cfg.Manifest)
|
||||
}
|
||||
s, err := collector.LoadFolderTreeStore(store)
|
||||
if err != nil {
|
||||
// 檔案還不存在=第一輪還沒跑完,那不是錯誤,是「還沒有」。
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if t, ok := s.Trees[path]; ok {
|
||||
return &t, nil
|
||||
}
|
||||
// 設定裡存的路徑與 collector 寫入時用的 absRoot 可能一個帶了尾斜線、
|
||||
// 一個沒有(AddFolder 直接存系統選擇器給的字串)⇒ 正規化後再找一次,
|
||||
// 不要因為一個斜線就跟使用者說「還沒回報」。
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
if t, ok := s.Trees[abs]; ok {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── 托盤會呼叫的兩個動作(t194)──
|
||||
|
||||
// ShowWindow 把主視窗叫出來並帶到前景。
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
package main
|
||||
|
||||
// apps.go — 桌面小幫手的 App 啟動器後端(inkstone/arcrun-rag#137)
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 這個檔案在解什麼
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面,
|
||||
// 每個 App 有一個 icon,**這會運行在 portal 及 daemon**。」
|
||||
//
|
||||
// 桌面這半沒有自己的 KBDB,也**不准**在本機另存一份 App 清單
|
||||
// (上游 `inkstone/Arcrun#82` 已定「安裝態只有一份真相源」=實例的 `{tenant}:app:{id}`)。
|
||||
// ⇒ 本檔做的事只有一件:**去使用者連的那個實例問**,把答案原樣交給畫面。
|
||||
// 沒有任何預設清單、沒有快取到磁碟、沒有第二份 schema。
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 為什麼是「兩條路」而不是一條(這是本票的規格判斷,理由寫在這裡不寫在票上)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 上游同一份安裝態有兩個對外面:
|
||||
//
|
||||
// ① `/portal/data/apps*` 認 **portal session**(Bearer)——Portal 那半走這條,
|
||||
// 清單/詳情(含畫面 HTML)/動作三支齊全。
|
||||
// ② `/apps` 認 **X-Arcrun-API-Key**(=實例 namespace)——CLI/操盤 AI 走這條,
|
||||
// **只有清單**,沒有詳情、沒有動作。
|
||||
//
|
||||
// 小幫手手上本來就有 ②的鑰匙(`config.json` 的 `api_key`,連線時實例自己下發的),
|
||||
// 但**沒有**①的 session(密碼刻意零落地,見 connect.go)。
|
||||
//
|
||||
// 所以:
|
||||
// - **清單一律先走①、拿不到就退回②**。兩支回的都是 `summarizeApp` 的同一個形狀
|
||||
// (id/name/icon/has_ui/version),所以九宮格**在完全沒登入的情況下也是真資料**。
|
||||
// - **詳情與動作只有①有**,所以那兩件需要一次 portal 登入。連線精靈當下順手換一張
|
||||
// session(`Connect` 已經有密碼在手,見 connect.go 的呼叫點),使用者通常不會再被問。
|
||||
// session 過期(實例預設 7 天)才會在 App 頁上出現一次登入框。
|
||||
//
|
||||
// 🔴 密碼仍然零落地:存的是 session token,不是密碼(與 connect.go 同一條紅線)。
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 不做輪詢
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 畫面每秒打一次 `GetState()`,但**本檔的任何函式都不掛在那條線上**——
|
||||
// App 清單只在「打開啟動器」與「按重新整理」時才去問實例一次。
|
||||
// 把網路請求塞進每秒的 tick 等於自己造一個輪詢器,那是明令禁止的形狀。
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// appHTTPTimeout:App 動作背後是一整條工作流(可能打 LLM),比單純讀清單久得多。
|
||||
// 清單/詳情用短的,動作用長的——不讓「讀清單」被一條慢工作流的預算拖著。
|
||||
const (
|
||||
appReadTimeout = 20 * time.Second
|
||||
appActionTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// ── 前端要的形狀 ───────────────────────────────────────────────────────────
|
||||
|
||||
// UIApp=九宮格上的一格。逐欄對應上游 `summarizeApp`,不多不少——
|
||||
// 多一欄就是在桌面端發明一個實例不認得的概念。
|
||||
type UIApp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
HasUI bool `json:"hasUi"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// UIAppList=一個知識庫的啟動器內容。
|
||||
//
|
||||
// 🔴 Error 與「空清單」是兩件事,不准混成同一句話(arcrun-rag#10「寧可明顯失敗」):
|
||||
// - `Error != ""` ⇒ 問不到(連不上/實例太舊沒有這支端點)——畫面要說「問不到」
|
||||
// - `Error == "" && len(Apps) == 0` ⇒ 真的一個都沒裝——畫面要說「還沒有 App」
|
||||
//
|
||||
// 兩者長得像,但使用者該做的事完全相反。
|
||||
type UIAppList struct {
|
||||
AccIdx int `json:"accIdx"`
|
||||
Account string `json:"account"` // 知識庫顯示名(畫面上的切換器用)
|
||||
Host string `json:"host"`
|
||||
Apps []UIApp `json:"apps"`
|
||||
Error string `json:"error"`
|
||||
// Source=這份清單是用哪把鑰匙問到的("session"/"apikey")。給診斷用,
|
||||
// 畫面不依它分支——兩條路的內容是同一份安裝態。
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// UIAppWorkflow=App 的一條工作流(沒有自帶畫面的 App 就靠這個列表當預設畫面,
|
||||
// 與 Portal 的 renderAppView 同一套呈現,不另立第二種)。
|
||||
type UIAppWorkflow struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UIAppDetail=點進一個 App 之後要的全部東西。
|
||||
type UIAppDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Version string `json:"version"`
|
||||
HasUI bool `json:"hasUi"`
|
||||
UIHtml string `json:"uiHtml"`
|
||||
Workflows []UIAppWorkflow `json:"workflows"`
|
||||
Actions []string `json:"actions"`
|
||||
// NeedsLogin=這台機器沒有(或已過期)這個知識庫的 portal session。
|
||||
// 不是錯誤,是「還差一步」——畫面要長出登入框,不是紅字。
|
||||
NeedsLogin bool `json:"needsLogin"`
|
||||
Email string `json:"email"` // 登入框預填(帳號本來就存在 config 裡)
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ── 內部:HTTP 小工具 ─────────────────────────────────────────────────────
|
||||
|
||||
// apiBaseOf 回傳這個帳號的 API base(cypher origin)。
|
||||
// 連線精靈存進來的 CypherURL 已經是 API 位址(normalizePortalURL 換算過),
|
||||
// 這裡只做去尾斜線,**不重新發明第二套換算**。
|
||||
func apiBaseOf(a accountCfg) string {
|
||||
return strings.TrimRight(strings.TrimSpace(a.CypherURL), "/")
|
||||
}
|
||||
|
||||
// accountAt 取第 idx 個帳號;越界回錯(前端傳來的索引可能落後於剛改過的設定)。
|
||||
func accountAt(cfg *directConfig, idx int) (accountCfg, error) {
|
||||
if cfg == nil || idx < 0 || idx >= len(cfg.Accounts) {
|
||||
return accountCfg{}, errors.New("找不到這個知識庫")
|
||||
}
|
||||
return cfg.Accounts[idx], nil
|
||||
}
|
||||
|
||||
// appDo 送一個請求並讀回 body。回傳 (status, body, err);err 只代表「連不上」,
|
||||
// HTTP 4xx/5xx 一律當成「連得上但被拒絕」由呼叫端判讀——
|
||||
// 把兩者混成一個 error 就分不出「網路斷了」與「密碼過期了」。
|
||||
func appDo(req *http.Request, timeout time.Duration) (int, []byte, error) {
|
||||
res, err := (&http.Client{Timeout: timeout}).Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("連不上這個知識庫——請確認網路正常")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) // App 自帶畫面可能不小,但給上限
|
||||
return res.StatusCode, b, nil
|
||||
}
|
||||
|
||||
// appErrMessage 從實例回的 JSON 撈 error 欄位;撈不到就用狀態碼講人話。
|
||||
func appErrMessage(status int, body []byte) string {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &e) == nil && strings.TrimSpace(e.Error) != "" {
|
||||
return e.Error
|
||||
}
|
||||
switch status {
|
||||
case http.StatusNotFound:
|
||||
return "這個知識庫還沒有 App 功能(實例版本較舊,更新後就會出現)"
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return "這個知識庫的登入已過期"
|
||||
}
|
||||
return fmt.Sprintf("知識庫回了 HTTP %d", status)
|
||||
}
|
||||
|
||||
// ── portal session(詳情與動作唯一的鑰匙)────────────────────────────────
|
||||
|
||||
// sessionValid:留 60 秒安全邊際,免得剛好在請求路上過期。
|
||||
func sessionValid(a accountCfg) bool {
|
||||
return strings.TrimSpace(a.PortalSession) != "" &&
|
||||
(a.PortalSessionExp == 0 || time.Now().Unix() < a.PortalSessionExp-60)
|
||||
}
|
||||
|
||||
// portalLogin 用帳密換一張 portal session token。
|
||||
//
|
||||
// 🔴 這支是**唯一**碰密碼的地方,而且密碼只活在參數裡:換完就丟,不寫檔、不 log。
|
||||
func portalLogin(base, email, password string) (token string, expiresIn int64, err error) {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": strings.ToLower(strings.TrimSpace(email)),
|
||||
"password": password,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/portal/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
status, raw, err := appDo(req, appReadTimeout)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
SessionToken string `json:"session_token"`
|
||||
SessionExpires int64 `json:"session_expires_in"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
if status != http.StatusOK || !out.Success || out.SessionToken == "" {
|
||||
if strings.TrimSpace(out.Error) != "" {
|
||||
return "", 0, errors.New(out.Error)
|
||||
}
|
||||
return "", 0, errors.New(appErrMessage(status, raw))
|
||||
}
|
||||
return out.SessionToken, out.SessionExpires, nil
|
||||
}
|
||||
|
||||
// storeSession 把換到的 session 寫回設定(只存 token 與到期時間,不存密碼)。
|
||||
func storeSession(idx int, token string, expiresIn int64) error {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx < 0 || idx >= len(cfg.Accounts) {
|
||||
return errors.New("找不到這個知識庫")
|
||||
}
|
||||
cfg.Accounts[idx].PortalSession = token
|
||||
if expiresIn > 0 {
|
||||
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + expiresIn
|
||||
} else {
|
||||
cfg.Accounts[idx].PortalSessionExp = 0
|
||||
}
|
||||
return saveCfg(cfg)
|
||||
}
|
||||
|
||||
// clearSession 在實例說「這張 session 不算數」時把它丟掉。
|
||||
// 留著一張已知無效的 token 只會讓下一次再撞一次 401,然後畫面又閃一下錯誤。
|
||||
func clearSession(idx int) {
|
||||
cfg, err := loadCfg()
|
||||
if err != nil || idx < 0 || idx >= len(cfg.Accounts) {
|
||||
return
|
||||
}
|
||||
if cfg.Accounts[idx].PortalSession == "" && cfg.Accounts[idx].PortalSessionExp == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Accounts[idx].PortalSession = ""
|
||||
cfg.Accounts[idx].PortalSessionExp = 0
|
||||
if err := saveCfg(cfg); err != nil {
|
||||
appLog("清掉過期的知識庫登入失敗:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tryStoreSessionFromLogin 在連線精靈成功之後順手換一張 session(connect.go 呼叫)。
|
||||
// **失敗不算連線失敗**——沒有 session 只是「App 詳情要再登入一次」,
|
||||
// 而資料夾同步這條主線根本用不到它。
|
||||
func tryStoreSessionFromLogin(cfg *directConfig, idx int, email, password string) {
|
||||
if idx < 0 || idx >= len(cfg.Accounts) {
|
||||
return
|
||||
}
|
||||
token, exp, err := portalLogin(apiBaseOf(cfg.Accounts[idx]), email, password)
|
||||
if err != nil {
|
||||
appLog("連線成功但沒換到知識庫的 App 登入(不影響同步):%v", err)
|
||||
return
|
||||
}
|
||||
cfg.Accounts[idx].PortalSession = token
|
||||
if exp > 0 {
|
||||
cfg.Accounts[idx].PortalSessionExp = time.Now().Unix() + exp
|
||||
}
|
||||
}
|
||||
|
||||
// ── 前端呼叫的四支 ─────────────────────────────────────────────────────────
|
||||
|
||||
// ListApps 回一個知識庫上「實際裝了的」App。
|
||||
//
|
||||
// 兩條路的順序與理由見檔頭。**沒有任何寫死的清單**——問不到就誠實說問不到。
|
||||
func (a *App) ListApps(accIdx int) UIAppList {
|
||||
out := UIAppList{AccIdx: accIdx}
|
||||
cfg, _ := loadCfg()
|
||||
acc, err := accountAt(cfg, accIdx)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
return out
|
||||
}
|
||||
out.Account = accountName(acc)
|
||||
out.Host = shortHost(acc.CypherURL)
|
||||
base := apiBaseOf(acc)
|
||||
if base == "" {
|
||||
out.Error = "這個知識庫沒有網址,請重新連線一次"
|
||||
return out
|
||||
}
|
||||
|
||||
// ① portal session(與 Portal 那半同一支端點、同一份權限)
|
||||
if sessionValid(acc) {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||||
status, raw, derr := appDo(req, appReadTimeout)
|
||||
switch {
|
||||
case derr != nil:
|
||||
out.Error = derr.Error()
|
||||
return out
|
||||
case status == http.StatusOK:
|
||||
out.Apps = parseAppList(raw)
|
||||
out.Source = "session"
|
||||
return out
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
clearSession(accIdx) // 過期了 ⇒ 掉到 ② 去,清單照樣看得到
|
||||
default:
|
||||
// 其他錯(例如 5xx)**不要**掉到 ②——那會把「實例出問題」偽裝成正常。
|
||||
out.Error = appErrMessage(status, raw)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// ② 實例 API key(小幫手本來就有的那把;只有清單,沒有詳情/動作)
|
||||
if strings.TrimSpace(acc.APIKey) == "" {
|
||||
out.Error = "這台電腦還沒有這個知識庫的鑰匙,請重新連線一次"
|
||||
return out
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/apps", nil)
|
||||
req.Header.Set("X-Arcrun-API-Key", acc.APIKey)
|
||||
status, raw, derr := appDo(req, appReadTimeout)
|
||||
if derr != nil {
|
||||
out.Error = derr.Error()
|
||||
return out
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
out.Error = appErrMessage(status, raw)
|
||||
return out
|
||||
}
|
||||
out.Apps = parseAppList(raw)
|
||||
out.Source = "apikey"
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAppList 讀 `{apps:[…]}`。兩支端點回的都是 summarizeApp 的形狀,所以只有一份解析。
|
||||
// 純函式(單測用)。
|
||||
func parseAppList(raw []byte) []UIApp {
|
||||
var body struct {
|
||||
Apps []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
HasUI bool `json:"has_ui"`
|
||||
Version string `json:"version"`
|
||||
} `json:"apps"`
|
||||
}
|
||||
if json.Unmarshal(raw, &body) != nil {
|
||||
return nil
|
||||
}
|
||||
apps := make([]UIApp, 0, len(body.Apps))
|
||||
for _, x := range body.Apps {
|
||||
name := x.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = x.ID
|
||||
}
|
||||
apps = append(apps, UIApp{ID: x.ID, Name: name, Icon: x.Icon, HasUI: x.HasUI, Version: x.Version})
|
||||
}
|
||||
return apps
|
||||
}
|
||||
|
||||
// GetApp 取一個 App 的詳情(自帶畫面/工作流清單/動作白名單)。
|
||||
//
|
||||
// 🔴 只有 portal session 這一條路——上游的 API key 面**沒有**詳情端點
|
||||
//
|
||||
// (`/apps` 只有列表;見 `cypher-executor/src/routes/apps.ts`)。
|
||||
// 所以沒有 session 時回 NeedsLogin,讓畫面長一次登入框,而不是回一句錯誤。
|
||||
func (a *App) GetApp(accIdx int, id string) UIAppDetail {
|
||||
out := UIAppDetail{ID: id}
|
||||
cfg, _ := loadCfg()
|
||||
acc, err := accountAt(cfg, accIdx)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
return out
|
||||
}
|
||||
out.Email = acc.Email
|
||||
if !sessionValid(acc) {
|
||||
out.NeedsLogin = true
|
||||
return out
|
||||
}
|
||||
base := apiBaseOf(acc)
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/portal/data/apps/"+urlPathEscape(id), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||||
status, raw, derr := appDo(req, appReadTimeout)
|
||||
if derr != nil {
|
||||
out.Error = derr.Error()
|
||||
return out
|
||||
}
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
clearSession(accIdx)
|
||||
out.NeedsLogin = true
|
||||
return out
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
out.Error = appErrMessage(status, raw)
|
||||
return out
|
||||
}
|
||||
var d struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Version string `json:"version"`
|
||||
HasUI bool `json:"has_ui"`
|
||||
UIHtml string `json:"ui_html"`
|
||||
Actions []string `json:"actions"`
|
||||
Workflows []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"workflows"`
|
||||
}
|
||||
if json.Unmarshal(raw, &d) != nil {
|
||||
out.Error = "這個知識庫回的內容看不懂(版本可能不相容)"
|
||||
return out
|
||||
}
|
||||
out.ID, out.Name, out.Icon, out.Version = d.ID, d.Name, d.Icon, d.Version
|
||||
out.HasUI, out.UIHtml, out.Actions = d.HasUI, d.UIHtml, d.Actions
|
||||
for _, w := range d.Workflows {
|
||||
out.Workflows = append(out.Workflows, UIAppWorkflow{Name: w.Name, Description: w.Description})
|
||||
}
|
||||
if strings.TrimSpace(out.Name) == "" {
|
||||
out.Name = out.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunAppAction 觸發 App 的一個白名單動作,回傳實例給的結果 JSON(原樣字串)。
|
||||
//
|
||||
// 🔴 白名單由**實例**裁決,不在這裡複製一份判斷(K6:前端/桌面端都不是裁決點)。
|
||||
//
|
||||
// 這裡不認得任何 action 名稱,只負責把使用者按的那顆按鈕送出去。
|
||||
//
|
||||
// payloadJSON 是前端組好的 JSON 物件字串;空字串=沒有 payload。
|
||||
func (a *App) RunAppAction(accIdx int, id, action, payloadJSON string) (string, error) {
|
||||
cfg, _ := loadCfg()
|
||||
acc, err := accountAt(cfg, accIdx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(action) == "" {
|
||||
return "", errors.New("沒有指定要做什麼")
|
||||
}
|
||||
if !sessionValid(acc) {
|
||||
return "", errors.New("這個知識庫的登入已過期,請在畫面上重新登入一次")
|
||||
}
|
||||
var payload any = map[string]any{}
|
||||
if s := strings.TrimSpace(payloadJSON); s != "" {
|
||||
if err := json.Unmarshal([]byte(s), &payload); err != nil {
|
||||
return "", errors.New("送出的內容格式不對")
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"action": action, "payload": payload})
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
apiBaseOf(acc)+"/portal/data/apps/"+urlPathEscape(id)+"/action", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+acc.PortalSession)
|
||||
status, raw, derr := appDo(req, appActionTimeout)
|
||||
if derr != nil {
|
||||
return "", derr
|
||||
}
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
// 403 也可能是「這個動作不在白名單」——那句話由實例自己講,這裡照抄。
|
||||
if status == http.StatusUnauthorized {
|
||||
clearSession(accIdx)
|
||||
}
|
||||
return "", errors.New(appErrMessage(status, raw))
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return "", errors.New(appErrMessage(status, raw))
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// PortalLogin 讓使用者在 App 頁上補一次登入(session 過期時)。
|
||||
// email 用 config 裡存的那個(就是他連線時用的帳號),只問密碼。
|
||||
func (a *App) PortalLogin(accIdx int, password string) error {
|
||||
cfg, _ := loadCfg()
|
||||
acc, err := accountAt(cfg, accIdx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(acc.Email) == "" {
|
||||
return errors.New("這個知識庫沒有記錄帳號,請用「新增知識庫帳號」重新連一次")
|
||||
}
|
||||
token, exp, err := portalLogin(apiBaseOf(acc), acc.Email, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storeSession(accIdx, token, exp)
|
||||
}
|
||||
|
||||
// urlPathEscape:App id 的字元集上游已限死 `^[a-z][a-z0-9_-]{0,63}$`,
|
||||
// 但我們是**收方**不是**發方**——照樣 escape,不假設對面永遠守規矩。
|
||||
func urlPathEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.', r == '~':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
for _, c := range []byte(string(r)) {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package main
|
||||
|
||||
// apps_test.go — App 啟動器後端的驗收(inkstone/arcrun-rag#137)
|
||||
//
|
||||
// 🔴 這裡的假實例**不是憑印象寫的**:每一支端點的路徑、認證標頭、回應欄位名,
|
||||
//
|
||||
// 都是照 `inkstone/Arcrun` 分支 `feat/app-system-v0` 的原始碼抄下來的
|
||||
// (`cypher-executor/src/routes/apps.ts`、`routes/portal-data.ts`、
|
||||
// `lib/app-system.ts` 的 summarizeApp/detailApp、`routes/portal.ts` 的 /portal/login)。
|
||||
// 抄錯了測試就會綠得很假——所以下面每一段都標了它對應上游哪一支。
|
||||
//
|
||||
// 這組測試回答的是驗收條件 1 與 2 裡「機器答得出來」的那幾格:
|
||||
// - 清單真的來自實例(兩條路都試過),桌面端沒有任何寫死清單
|
||||
// - 「問不到」與「一個都沒裝」分得出來
|
||||
// - session 過期會被清掉、且清單仍然看得到(退回 API key)
|
||||
// - 動作真的打到 /portal/data/apps/:id/action,白名單由實例裁決
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeCfgWithAccount 造一份最小可用的 config.json(一個知識庫)。
|
||||
func writeCfgWithAccount(t *testing.T, base string, acc accountCfg) {
|
||||
t.Helper()
|
||||
acc.CypherURL = base
|
||||
if acc.Namespace == "" {
|
||||
acc.Namespace = "ns-test"
|
||||
}
|
||||
cfg := map[string]any{
|
||||
"manifest": filepath.Join(appDir(), "manifest.json"),
|
||||
"accounts": []accountCfg{acc},
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(appDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath(), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func tempHome(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
|
||||
// fakeInstance 是「一台裝了 App 系統的 Arcrun 實例」的最小替身。
|
||||
type fakeInstance struct {
|
||||
// sessionToken=目前有效的 session;空字串=任何 Bearer 都算過期。
|
||||
sessionToken string
|
||||
password string
|
||||
apiKey string
|
||||
// 收到的動作(給測試斷言用)
|
||||
gotAction string
|
||||
gotPayload map[string]any
|
||||
// 讓測試模擬「實例壞掉」
|
||||
listStatus int
|
||||
}
|
||||
|
||||
func (f *fakeInstance) server(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
// 上游 summarizeApp 的形狀(lib/app-system.ts)
|
||||
summary := []map[string]any{
|
||||
{"id": "note", "name": "筆記", "icon": "🗒️", "has_ui": true, "version": "0.1.0"},
|
||||
{"id": "weekly", "name": "週報", "icon": "📊", "has_ui": false, "version": "0.2.0"},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// POST /portal/login(routes/portal.ts)
|
||||
mux.HandleFunc("/portal/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Email, Password string }
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in.Password != f.password {
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "email 或密碼錯誤"})
|
||||
return
|
||||
}
|
||||
f.sessionToken = "sess-" + in.Email
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true, "session_token": f.sessionToken, "session_expires_in": 604800,
|
||||
})
|
||||
})
|
||||
|
||||
// GET /apps(routes/apps.ts,X-Arcrun-API-Key)
|
||||
mux.HandleFunc("/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Arcrun-API-Key") != f.apiKey {
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "缺少 X-Arcrun-API-Key header"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)})
|
||||
})
|
||||
|
||||
// /portal/data/apps*(routes/portal-data.ts,Bearer portal session)
|
||||
mux.HandleFunc("/portal/data/apps", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authed(r) {
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"})
|
||||
return
|
||||
}
|
||||
if f.listStatus != 0 {
|
||||
w.WriteHeader(f.listStatus)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "實例內部錯誤"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)})
|
||||
})
|
||||
mux.HandleFunc("/portal/data/apps/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !f.authed(r) {
|
||||
w.WriteHeader(401)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"})
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/portal/data/apps/")
|
||||
if strings.HasSuffix(rest, "/action") {
|
||||
id := strings.TrimSuffix(rest, "/action")
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
// 上游 runAppAction:白名單不中 → 403(K6,裁決點在實例不在呼叫端)
|
||||
if id != "note" || in.Action != "notes-create" {
|
||||
w.WriteHeader(403)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "這個動作不在這個 App 的白名單內"})
|
||||
return
|
||||
}
|
||||
f.gotAction, f.gotPayload = in.Action, in.Payload
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{"id": "blk_1"}})
|
||||
return
|
||||
}
|
||||
if rest != "note" {
|
||||
w.WriteHeader(404)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "找不到這個 App"})
|
||||
return
|
||||
}
|
||||
// 上游 detailApp 的形狀
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "note", "name": "筆記", "icon": "🗒️", "version": "0.1.0",
|
||||
"has_ui": true, "ui_html": "<div id=x>hi</div>",
|
||||
"workflows": []map[string]string{{"name": "notes-create", "description": "寫入一則筆記"}},
|
||||
"actions": []string{"notes-create"},
|
||||
})
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func (f *fakeInstance) authed(r *http.Request) bool {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return f.sessionToken != "" && tok == f.sessionToken
|
||||
}
|
||||
|
||||
// ── 驗收條件 1:清單來自實例,兩條路都通 ────────────────────────────────
|
||||
|
||||
func TestListApps_UsesPortalSessionWhenAvailable(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{
|
||||
InstanceName: "我的庫", Email: "a@b.c", APIKey: "ns-test",
|
||||
PortalSession: "sess-a", PortalSessionExp: 0,
|
||||
})
|
||||
|
||||
got := (&App{}).ListApps(0)
|
||||
if got.Error != "" {
|
||||
t.Fatalf("不該有錯:%s", got.Error)
|
||||
}
|
||||
if got.Source != "session" {
|
||||
t.Fatalf("該走 portal session,實際走 %q", got.Source)
|
||||
}
|
||||
if len(got.Apps) != 2 || got.Apps[0].ID != "note" || got.Apps[0].Icon != "🗒️" || !got.Apps[0].HasUI {
|
||||
t.Fatalf("清單沒有原樣接住實例的回應:%+v", got.Apps)
|
||||
}
|
||||
if got.Account != "我的庫" {
|
||||
t.Fatalf("知識庫名字不對:%q", got.Account)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_FallsBackToApiKeyWithoutSession(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
// 完全沒有 session(=剛升級上來、或從沒登入過)——九宮格照樣要看得到真資料
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"})
|
||||
|
||||
got := (&App{}).ListApps(0)
|
||||
if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 {
|
||||
t.Fatalf("沒有 session 時該退回 API key 並照樣列出來:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_ExpiredSessionIsClearedAndListStillWorks(t *testing.T) {
|
||||
tempHome(t)
|
||||
// 實例已經不認這張 session(f.sessionToken 與 config 裡那張不同)
|
||||
fi := &fakeInstance{sessionToken: "sess-new", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{
|
||||
Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-old",
|
||||
})
|
||||
|
||||
got := (&App{}).ListApps(0)
|
||||
if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 {
|
||||
t.Fatalf("session 過期時清單仍該看得到(退回 API key):%+v", got)
|
||||
}
|
||||
// 已知無效的 token 要被丟掉,否則下一次還會再撞一次 401
|
||||
cfg, _ := loadCfg()
|
||||
if cfg.Accounts[0].PortalSession != "" {
|
||||
t.Fatalf("過期的 session 沒有被清掉:%q", cfg.Accounts[0].PortalSession)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴「問不到」不准偽裝成「一個都沒裝」——實例 5xx 時不准悄悄退回 API key 假裝正常。
|
||||
func TestListApps_InstanceErrorIsNotDisguisedAsEmpty(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test", listStatus: 500}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"})
|
||||
|
||||
got := (&App{}).ListApps(0)
|
||||
if got.Error == "" {
|
||||
t.Fatalf("實例回 500 卻沒有回報錯誤:%+v", got)
|
||||
}
|
||||
if len(got.Apps) != 0 {
|
||||
t.Fatalf("出錯時不該給半份清單:%+v", got.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_UnknownAccountSaysSo(t *testing.T) {
|
||||
tempHome(t)
|
||||
writeCfgWithAccount(t, "https://example.invalid", accountCfg{Email: "a@b.c", APIKey: "k"})
|
||||
if got := (&App{}).ListApps(7); got.Error == "" {
|
||||
t.Fatalf("越界的索引該回錯,不是空清單")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 驗收條件 2:點進去能用 ───────────────────────────────────────────────
|
||||
|
||||
func TestGetApp_ReturnsDetailWithUI(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"})
|
||||
|
||||
d := (&App{}).GetApp(0, "note")
|
||||
if d.Error != "" || d.NeedsLogin {
|
||||
t.Fatalf("不該有錯也不該要登入:%+v", d)
|
||||
}
|
||||
if !d.HasUI || d.UIHtml != "<div id=x>hi</div>" {
|
||||
t.Fatalf("自帶畫面沒接住:%+v", d)
|
||||
}
|
||||
if len(d.Workflows) != 1 || d.Workflows[0].Name != "notes-create" {
|
||||
t.Fatalf("工作流清單沒接住:%+v", d.Workflows)
|
||||
}
|
||||
if len(d.Actions) != 1 || d.Actions[0] != "notes-create" {
|
||||
t.Fatalf("動作白名單沒接住:%+v", d.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// 沒有 session ⇒ 回 NeedsLogin(要長登入框),不是回一句錯誤。
|
||||
// 這是刻意的:上游的 API key 面**沒有**詳情端點(routes/apps.ts 只有 install/list/delete),
|
||||
// 所以「看詳情」在協定上就只有 portal session 那一條路。
|
||||
func TestGetApp_WithoutSessionAsksForLoginNotError(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"})
|
||||
|
||||
d := (&App{}).GetApp(0, "note")
|
||||
if !d.NeedsLogin {
|
||||
t.Fatalf("沒有 session 時該回 NeedsLogin:%+v", d)
|
||||
}
|
||||
if d.Error != "" {
|
||||
t.Fatalf("這不是錯誤,是還差一步:%q", d.Error)
|
||||
}
|
||||
if d.Email != "a@b.c" {
|
||||
t.Fatalf("登入框要能預填帳號:%q", d.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalLogin_StoresSessionNotPassword(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"})
|
||||
|
||||
if err := (&App{}).PortalLogin(0, "wrong"); err == nil {
|
||||
t.Fatalf("密碼錯了應該要失敗")
|
||||
}
|
||||
if err := (&App{}).PortalLogin(0, "pw"); err != nil {
|
||||
t.Fatalf("密碼對了卻失敗:%v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 🔴 密碼零落地:整份 config 裡不准出現密碼字串
|
||||
if strings.Contains(string(raw), "pw\"") || strings.Contains(string(raw), "\"password\"") {
|
||||
t.Fatalf("密碼被寫進 config 了:%s", raw)
|
||||
}
|
||||
cfg, _ := loadCfg()
|
||||
if cfg.Accounts[0].PortalSession == "" || cfg.Accounts[0].PortalSessionExp == 0 {
|
||||
t.Fatalf("session 沒有存起來:%+v", cfg.Accounts[0])
|
||||
}
|
||||
// 存了 session 之後,詳情就拿得到了
|
||||
if d := (&App{}).GetApp(0, "note"); d.NeedsLogin || d.Error != "" {
|
||||
t.Fatalf("登入之後詳情還是拿不到:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppAction_HitsInstanceAndCarriesPayload(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"})
|
||||
|
||||
out, err := (&App{}).RunAppAction(0, "note", "notes-create", `{"text":"嗨"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("動作失敗:%v", err)
|
||||
}
|
||||
if !strings.Contains(out, "blk_1") {
|
||||
t.Fatalf("沒有把實例的結果原樣帶回來:%s", out)
|
||||
}
|
||||
if fi.gotAction != "notes-create" || fi.gotPayload["text"] != "嗨" {
|
||||
t.Fatalf("payload 沒送到:%q %+v", fi.gotAction, fi.gotPayload)
|
||||
}
|
||||
}
|
||||
|
||||
// 白名單是**實例**裁決的(K6)——桌面端不複製一份判斷,只負責把實例的話原樣說出來。
|
||||
func TestRunAppAction_WhitelistIsDecidedByInstance(t *testing.T) {
|
||||
tempHome(t)
|
||||
fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"}
|
||||
srv := fi.server(t)
|
||||
defer srv.Close()
|
||||
writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"})
|
||||
|
||||
_, err := (&App{}).RunAppAction(0, "note", "rm-rf", "")
|
||||
if err == nil {
|
||||
t.Fatalf("不在白名單的動作該失敗")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "白名單") {
|
||||
t.Fatalf("該把實例的原話帶回來,實際:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 純函式 ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseAppList_FallsBackToIdWhenNameMissing(t *testing.T) {
|
||||
apps := parseAppList([]byte(`{"apps":[{"id":"x","name":"","icon":"","has_ui":false,"version":"1"}]}`))
|
||||
if len(apps) != 1 || apps[0].Name != "x" {
|
||||
t.Fatalf("沒有名字時該退回 id:%+v", apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUrlPathEscape(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"note": "note",
|
||||
"a/b": "a%2Fb",
|
||||
"a b": "a%20b",
|
||||
"../etc": "..%2Fetc",
|
||||
"筆記": "%E7%AD%86%E8%A8%98",
|
||||
} {
|
||||
if got := urlPathEscape(in); got != want {
|
||||
t.Fatalf("urlPathEscape(%q)=%q,想要 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// check-launcher.mjs — App 啟動器的**看得見的驗收**(inkstone/arcrun-rag#137)
|
||||
//
|
||||
// 🔴 leo 2026-08-04:「既然是 Web,你做的時候**無法檢視**?」
|
||||
// ⇒ 這支就是那句話對 App 啟動器的兌現:真的把 frontend/dist 渲染出來、
|
||||
// 真的按下去、真的量畫面上有什麼,而不是「看程式碼覺得應該會動」。
|
||||
//
|
||||
// 與旁邊 check-render.sh 的分工:
|
||||
// · check-render.sh 量 CIS 的像素(lockup 接合/深色模式)——只跑得動 macOS 的 Chrome
|
||||
// · 本檔 驗 App 啟動器的**行為**(九宮格/點進去/sandbox 橋/既有頁沒壞)
|
||||
// 用 playwright 的 chromium,macOS/Linux/CI 都跑得動
|
||||
//
|
||||
// 跑法:`node check-launcher.mjs`(沒裝 playwright 就自己跳過,不擋別人的流程)
|
||||
// 產出:截圖在 /tmp/arcrun-launcher-shots/,每一條檢查印一行 ✅/❌
|
||||
//
|
||||
// ⚠️ 這支驗的是**前端這一半**(畫面、互動、sandbox 邊界)。後端那一半
|
||||
// (真的去問實例、session/API key 兩條路、白名單由實例裁決)在 apps_test.go,
|
||||
// 那裡打的是照上游原始碼寫的假實例。**兩邊都綠才算前後端一體。**
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dist = path.join(here, 'frontend', 'dist');
|
||||
const shots = '/tmp/arcrun-launcher-shots';
|
||||
|
||||
// playwright 從哪裡來:本地 node_modules 優先,其次全域安裝(CI/雲端常見)。
|
||||
// CommonJS 包從 ESM import 進來時,具名匯出可能只在 default 上,兩種都接。
|
||||
async function loadChromium() {
|
||||
const tries = ['playwright', '/opt/node22/lib/node_modules/playwright/index.js'];
|
||||
for (const spec of tries) {
|
||||
try {
|
||||
const m = await import(spec);
|
||||
const c = m.chromium || (m.default && m.default.chromium);
|
||||
if (c) return c;
|
||||
} catch { /* 下一個 */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const chromium = await loadChromium();
|
||||
if (!chromium) {
|
||||
console.log('⚠️ 沒有 playwright,跳過畫面驗收(npm i -D playwright && npx playwright install chromium)');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!fs.existsSync(path.join(dist, 'index.html'))) {
|
||||
console.error('❌ 沒有 frontend/dist——先 npm --prefix frontend run build');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 靜態伺服器(dist 原樣,不改任何一個 byte)──
|
||||
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' };
|
||||
const srv = http.createServer((req, res) => {
|
||||
const rel = decodeURIComponent(req.url.split('?')[0]);
|
||||
const f = path.join(dist, rel === '/' ? 'index.html' : rel);
|
||||
if (!f.startsWith(dist) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404).end(); return; }
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'application/octet-stream' });
|
||||
fs.createReadStream(f).pipe(res);
|
||||
});
|
||||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||||
const origin = `http://127.0.0.1:${srv.address().port}`;
|
||||
|
||||
// ── 假後端:形狀逐欄對齊 apps.go 的 UIAppList/UIAppDetail ──
|
||||
//
|
||||
// 🔴 這裡**不是**在假裝 Go 那半會怎麼運作(那由 apps_test.go 打真 HTTP 驗)。
|
||||
// 這裡只回「Go 已經回過來的東西長什麼樣」,讓畫面有東西可畫。
|
||||
const mock = (scenario) => `
|
||||
const scenario = ${JSON.stringify(scenario)};
|
||||
window.__scenario = scenario;
|
||||
window.__calls = [];
|
||||
const APPS = [
|
||||
{ id: 'note', name: '筆記', icon: '🗒️', hasUi: true, version: '0.1.0' },
|
||||
{ id: 'weekly', name: '週報', icon: '📊', hasUi: false, version: '0.2.0' },
|
||||
];
|
||||
const DETAIL = {
|
||||
note: { id:'note', name:'筆記', icon:'🗒️', version:'0.1.0', hasUi:true,
|
||||
uiHtml: '<h3 id="t">河道</h3><button id="go">寫一則</button><pre id="out"></pre>' +
|
||||
'<scr'+'ipt>document.getElementById("go").onclick=async()=>{' +
|
||||
'const r=await window.arcrunApp.action("notes-create",{text:"嗨"});' +
|
||||
'document.getElementById("out").textContent=JSON.stringify(r);};' +
|
||||
'document.title="app-ui-loaded";' +
|
||||
'window.__peek=(()=>{try{return typeof parent.go}catch(e){return "BLOCKED:"+e.name}})();' +
|
||||
'</scr'+'ipt>',
|
||||
workflows:[{name:'notes-create',description:'寫入一則筆記'}], actions:['notes-create'] },
|
||||
weekly: { id:'weekly', name:'週報', icon:'📊', version:'0.2.0', hasUi:false, uiHtml:'',
|
||||
workflows:[{name:'weekly-report',description:'每週一產週報'}], actions:['weekly-report'] },
|
||||
};
|
||||
const ACCOUNTS = [{
|
||||
name:'我的知識庫', host:'arcrun-cypher-executor.demo.workers.dev', folders:[{path:'/Users/leo/Notes',accIdx:0}],
|
||||
cloudVerKnown:true, cloudVerStale:false, cloudVerMine:'1.4.42', email:'leo@example.com',
|
||||
}];
|
||||
window.go = { main: { App: {
|
||||
GetState: async () => ({
|
||||
version:'v0.18.34', statusBig:'看守中 · 資料夾有變動就會自動整理', statusSub:'上次檢查 10:21',
|
||||
syncing:false, engine:'workers-ai', geminiKey:'',
|
||||
accounts: scenario === 'noaccount' ? [] : ACCOUNTS,
|
||||
steps:[{title:'看守資料夾',meta:'有變動就自動開始',state:'done'},
|
||||
{title:'發現變化',meta:'上次 10:20',state:'done'},
|
||||
{title:'用 AI 整理成知識卡',state:'done'},
|
||||
{title:'上傳到你的知識庫',meta:'上次 3 份',state:'done'}],
|
||||
skipped:null, engineTrouble:false, logFolder:'/Users/leo/.arcrun-rag',
|
||||
progress:{total:120,done:117,pending:3,cantSync:0,groups:[]}, quota:null,
|
||||
}),
|
||||
ListApps: async (i) => {
|
||||
window.__calls.push(['ListApps', i]);
|
||||
if (scenario === 'listerror') return { accIdx:i, account:'我的知識庫', apps:[], error:'連不上這個知識庫——請確認網路正常' };
|
||||
if (scenario === 'noapps') return { accIdx:i, account:'我的知識庫', apps:[], error:'', source:'session' };
|
||||
return { accIdx:i, account:'我的知識庫', host:'demo', apps:APPS, error:'', source:'session' };
|
||||
},
|
||||
GetApp: async (i, id) => {
|
||||
window.__calls.push(['GetApp', i, id]);
|
||||
if (scenario === 'needlogin') return { id, name:'筆記', icon:'🗒️', needsLogin:true, email:'leo@example.com', workflows:[], actions:[] };
|
||||
return DETAIL[id];
|
||||
},
|
||||
RunAppAction: async (i, id, action, payload) => {
|
||||
window.__calls.push(['RunAppAction', i, id, action, payload]);
|
||||
if (action !== 'notes-create' && action !== 'weekly-report') throw new Error('這個動作不在這個 App 的白名單內');
|
||||
return JSON.stringify({ ok:true, result:{ id:'blk_1', echo: JSON.parse(payload||'{}') } });
|
||||
},
|
||||
PortalLogin: async (i, pw) => { window.__calls.push(['PortalLogin', i]); if (pw !== 'pw') throw new Error('email 或密碼錯誤'); },
|
||||
SyncNow: async () => {}, PickFolder: async () => '', AddFolder: async () => {}, RemoveFolder: async () => {},
|
||||
SetAI: async () => {}, OpenURL: () => { window.__calls.push(['OpenURL']); }, OpenLogFolder: async () => {},
|
||||
Connect: async () => {}, CheckUpdate: async () => ({ latest:'v0.18.34' }),
|
||||
DownloadUpdate: async () => ({}), ApplyUpdate: async () => {}, ExportDiagnostics: async () => '',
|
||||
}}};
|
||||
`;
|
||||
|
||||
let fail = 0;
|
||||
const ok = (m) => console.log(' ✅ ' + m);
|
||||
const ng = (m) => { console.log(' ❌ ' + m); fail = 1; };
|
||||
const check = (cond, m) => (cond ? ok(m) : ng(m));
|
||||
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
|
||||
async function open(scenario, theme = 'light') {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 } });
|
||||
const page = await ctx.newPage();
|
||||
await page.addInitScript(`try{localStorage.setItem('arcrun_app_theme','${theme}')}catch(e){}`);
|
||||
await page.addInitScript(mock(scenario));
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(String(e)));
|
||||
await page.goto(origin);
|
||||
await page.waitForTimeout(400);
|
||||
return { ctx, page, errs };
|
||||
}
|
||||
|
||||
console.log('━━━ App 啟動器畫面驗收 ━━━');
|
||||
|
||||
// ① 打開就是九宮格,磁磚是真資料(不是寫死的)
|
||||
{
|
||||
const { ctx, page, errs } = await open('ok');
|
||||
await page.waitForSelector('.appgrid', { timeout: 5000 }).catch(() => {});
|
||||
const tiles = await page.$$eval('.appcell .nm', (n) => n.map((e) => e.textContent.trim()));
|
||||
check(errs.length === 0, `啟動器沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
|
||||
check(await page.$('.appgrid') !== null, '打開就看到九宮格');
|
||||
check(tiles.join('|') === '筆記|週報|加裝 App', `磁磚=實例回的那兩個 App + 加裝(實際:${tiles.join('|')})`);
|
||||
check((await page.$eval('.apphead .s', (e) => e.textContent)).includes('2 個 App 已安裝'), '頁首說得出裝了幾個');
|
||||
const calls = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
|
||||
check(calls === 1, `只問了實例一次,不是每秒輪詢(實際 ${calls} 次)`);
|
||||
await page.waitForTimeout(2500);
|
||||
const calls2 = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
|
||||
check(calls2 === 1, `2.5 秒後仍然只問過一次(實際 ${calls2} 次)— 沒有掛在每秒的 tick 上`);
|
||||
await page.screenshot({ path: `${shots}/01-launcher-light.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ①b 深色模式也要對
|
||||
{
|
||||
const { ctx, page } = await open('ok', 'dark');
|
||||
await page.waitForSelector('.appgrid');
|
||||
await page.screenshot({ path: `${shots}/02-launcher-dark.png` });
|
||||
check(await page.getAttribute('html', 'data-theme') === 'dark', '深色模式渲染得出來');
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ② 點一個「有自帶畫面」的 App → 進 sandbox iframe,且橋真的通
|
||||
{
|
||||
const { ctx, page, errs } = await open('ok');
|
||||
await page.waitForSelector('.appgrid');
|
||||
await page.click('[data-appopen="note"]');
|
||||
await page.waitForSelector('#appFrame', { timeout: 5000 });
|
||||
const frame = await (await page.$('#appFrame')).contentFrame();
|
||||
await frame.waitForSelector('#go', { timeout: 5000 });
|
||||
check(true, '點「筆記」進得去,App 自帶畫面渲染出來');
|
||||
// 🔴 sandbox 邊界:iframe 裡的 script 不准碰得到 parent 的 window.go
|
||||
// (那上面掛著 RemoveFolder(takedown)/SetAI/Connect)
|
||||
const peek = await frame.evaluate(() => window.__peek);
|
||||
check(typeof peek === 'string' && peek.startsWith('BLOCKED'), `iframe 碰不到 parent 的 window.go(實際:${peek})`);
|
||||
// 橋:iframe 按鈕 → postMessage → go.RunAppAction → 回到 iframe
|
||||
await frame.click('#go');
|
||||
await frame.waitForFunction(() => document.getElementById('out').textContent.length > 0, null, { timeout: 5000 });
|
||||
const out = await frame.$eval('#out', (e) => e.textContent);
|
||||
check(out.includes('blk_1'), `sandbox 橋走得通,結果回得到 App 畫面(實際:${out.slice(0, 80)})`);
|
||||
const call = await page.evaluate(() => window.__calls.find((c) => c[0] === 'RunAppAction'));
|
||||
check(call && call[3] === 'notes-create' && JSON.parse(call[4]).text === '嗨', 'payload 原樣送到後端');
|
||||
check(errs.length === 0, `App 頁沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
|
||||
await page.screenshot({ path: `${shots}/03-app-ui.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ②b 沒有自帶畫面的 App → 工作流清單 +「現在執行」
|
||||
{
|
||||
const { ctx, page } = await open('ok');
|
||||
await page.waitForSelector('.appgrid');
|
||||
await page.click('[data-appopen="weekly"]');
|
||||
await page.waitForSelector('.wfitem', { timeout: 5000 });
|
||||
check(await page.$eval('.wfitem .nm', (e) => e.textContent) === 'weekly-report', '沒有畫面的 App 列出它的工作流');
|
||||
await page.click('[data-apprun="weekly-report"]');
|
||||
await page.waitForFunction(() => document.querySelector('.wfitem .out').textContent.includes('完成'), null, { timeout: 5000 });
|
||||
check(true, '「現在執行」按得動,結果顯示在那一列底下');
|
||||
await page.screenshot({ path: `${shots}/04-app-workflows.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ③ 三種「不是正常」的狀態都要說人話,且分得出來
|
||||
{
|
||||
const { ctx, page } = await open('noapps');
|
||||
await page.waitForSelector('.appgrid');
|
||||
const txt = await page.textContent('#page');
|
||||
check(txt.includes('這個知識庫還沒有 App'), '一個都沒裝 → 說「還沒有 App」並教怎麼裝');
|
||||
check(!txt.includes('連不上'), '一個都沒裝時不准講成連線失敗');
|
||||
await page.screenshot({ path: `${shots}/05-empty.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
{
|
||||
const { ctx, page } = await open('listerror');
|
||||
await page.waitForTimeout(300);
|
||||
const txt = await page.textContent('#page');
|
||||
check(txt.includes('暫時看不到') && txt.includes('連不上'), '問不到 → 說「問不到」並帶實例的原話');
|
||||
check(!txt.includes('還沒有 App'), '問不到時不准講成「一個都沒裝」');
|
||||
await page.screenshot({ path: `${shots}/06-error.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
{
|
||||
const { ctx, page } = await open('noaccount');
|
||||
await page.waitForTimeout(300);
|
||||
const txt = await page.textContent('#page');
|
||||
check(txt.includes('歡迎使用 Arcrun') || txt.includes('還沒有連上知識庫'), '沒連任何實例 → 說人話(連線精靈),不是空白');
|
||||
await page.screenshot({ path: `${shots}/07-noaccount.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
{
|
||||
const { ctx, page } = await open('needlogin');
|
||||
await page.waitForSelector('.appgrid');
|
||||
await page.click('[data-appopen="note"]');
|
||||
await page.waitForSelector('#apPw', { timeout: 5000 });
|
||||
check(await page.inputValue('#apEmail') === 'leo@example.com', 'session 過期 → 長登入框並預填帳號');
|
||||
await page.screenshot({ path: `${shots}/08-needlogin.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// ④ 驗收條件 3:現有功能一項都沒少
|
||||
{
|
||||
const { ctx, page, errs } = await open('ok');
|
||||
await page.waitForSelector('.appgrid');
|
||||
const navs = await page.$$eval('#side .nav .nm', (n) => n.map((e) => e.textContent.trim()));
|
||||
for (const want of ['App 界面', '首頁', '我的知識庫', 'AI 設定', '版本與更新']) {
|
||||
check(navs.includes(want), `側欄仍有「${want}」`);
|
||||
}
|
||||
const pages = [
|
||||
['home', '現在的狀態'],
|
||||
['lib:0', '開啟知識庫網頁'],
|
||||
['ai', '用哪個 AI 幫你整理文件'],
|
||||
['update', '版本與更新'],
|
||||
];
|
||||
for (const [p, marker] of pages) {
|
||||
await page.click(`#side .nav[data-p="${p}"]`);
|
||||
await page.waitForTimeout(200);
|
||||
const t = await page.textContent('#page');
|
||||
check(t.includes(marker), `「${p}」頁還在(找得到「${marker}」)`);
|
||||
}
|
||||
// 全站頁首的同步狀態與「立刻同步」在每一頁都看得到(首頁換走了它們也沒消失)
|
||||
check((await page.textContent('#statusBig')).includes('看守中'), '同步狀態在全站頁首,換頁不會不見');
|
||||
check(await page.$('#btnSync') !== null, '「立刻同步」按鈕還在');
|
||||
check(errs.length === 0, `逛完全部頁面沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
|
||||
await page.screenshot({ path: `${shots}/09-existing-update.png` });
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
srv.close();
|
||||
console.log(`\n截圖:${shots}`);
|
||||
console.log(fail ? '\n❌ 畫面驗收未過' : '\n✅ 畫面驗收全過');
|
||||
process.exit(fail);
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-remove-cleanup.sh — 驗**使用者真的會走的那條路**:
|
||||
# 從各庫頁按「移除」→ 對話框 → 勾「順便清掉」→ 看到清單 → 按確定(arcrun-rag#138)。
|
||||
#
|
||||
# 🔴 為什麼要這一支:Go 那邊的迴歸網只證明「函式做對事」,證明不了
|
||||
# 「按鈕存在、清單真的攤出來、第四個參數真的傳下去」。#138 的驗收條件裡
|
||||
# 「使用者要能在動手前看到將要刪掉哪些東西」**只有前端驗得到**。
|
||||
# (同 check-render.sh 的理由:改完 UI 不准拿沒看過的畫面交件。)
|
||||
#
|
||||
# 做法沿用 check-render.sh:把 dist 複製到暫存、插一支 mock.js 假扮 window.go,
|
||||
# 再用 headless Chrome 真的載入、真的點下去,最後 --dump-dom 讀結果。
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
[ -x "$CHROME" ] || { echo "❌ 找不到 Chrome,跳過"; exit 0; }
|
||||
[ -d frontend/dist ] || { echo "❌ 沒有 frontend/dist——先 (cd frontend && npm run build)"; exit 1; }
|
||||
|
||||
W=$(mktemp -d)
|
||||
SRV_PID=""
|
||||
cleanup() { rm -rf "$W"; [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
lsof -ti tcp:8798 2>/dev/null | xargs -r kill 2>/dev/null; sleep 0.3
|
||||
cp -R frontend/dist/* "$W/"
|
||||
|
||||
cat > "$W/mock.js" <<'JS'
|
||||
// 假的 window.go:一個知識庫、一個看守中的資料夾,PlanFolderCleanup 回一份
|
||||
// 形狀與 collector.CleanupPlan 完全一致的清單(含「留著不動」那一半)。
|
||||
window.__calls = [];
|
||||
window.go = { main: { App: {
|
||||
GetState: async () => ({
|
||||
version: 'v0', statusBig: '看守中', statusSub: '', syncing: false,
|
||||
engine: 'workers-ai', geminiKey: '', extractedOK: 0,
|
||||
accounts: [{ name: '測試庫', host: 'arcrun-cypher-executor.example.workers.dev',
|
||||
folders: [{ path: '/kb/pms', accIdx: 0, retiring: false }] }],
|
||||
}),
|
||||
PlanFolderCleanup: async (acc, path) => {
|
||||
window.__calls.push(['PlanFolderCleanup', acc, path]);
|
||||
return {
|
||||
root: path, files: 52, bytes: 1234,
|
||||
remove: [
|
||||
{ rel: '.arcrun-rag', is_dir: true, kind: 'workspace', evidence: '依據甲', files: 1, bytes: 10 },
|
||||
{ rel: '.wiki', is_dir: true, kind: 'wiki-dir', evidence: '依據乙', files: 8, bytes: 100 },
|
||||
{ rel: 'docs/.wiki', is_dir: true, kind: 'wiki-dir', evidence: '依據乙', files: 43, bytes: 900 },
|
||||
],
|
||||
keep: [{ rel: 'pms_v1_legacy', reason: '這個資料夾也還在同步清單裡' }],
|
||||
};
|
||||
},
|
||||
RemoveFolder: async (acc, path, takedown, cleanupLocal) => {
|
||||
window.__calls.push(['RemoveFolder', acc, path, takedown, cleanupLocal]);
|
||||
},
|
||||
SyncNow: async () => {}, PickFolder: async () => '', AddFolder: async () => {},
|
||||
SetAI: async () => {}, OpenURL: () => {}, Connect: async () => {},
|
||||
CheckUpdate: async () => ({}), OpenLogFolder: () => {}, LogFolderPath: () => '',
|
||||
ListApps: async () => ({ apps: [] }),
|
||||
} } };
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const say = (k, v) => {
|
||||
const d = document.createElement('div');
|
||||
d.setAttribute('data-check', k); d.textContent = String(v);
|
||||
document.body.appendChild(d);
|
||||
};
|
||||
|
||||
addEventListener('DOMContentLoaded', async () => {
|
||||
await sleep(600); // 等 main.js 拿到 state、畫完側欄
|
||||
// 走到各庫頁(使用者點側邊欄那個庫)
|
||||
const nav = [...document.querySelectorAll('.nav')].find((b) => (b.dataset.p || '').startsWith('lib:'));
|
||||
if (nav) nav.click(); else location.hash = '';
|
||||
await sleep(300);
|
||||
|
||||
const rm = document.querySelector('[data-rm]');
|
||||
say('有移除按鈕', !!rm);
|
||||
if (!rm) { say('done', 1); return; }
|
||||
rm.click();
|
||||
await sleep(200);
|
||||
|
||||
const box = document.getElementById('rmClean');
|
||||
say('有清理勾選框', !!box);
|
||||
say('預設不勾', box ? box.checked === false : false);
|
||||
if (!box) { say('done', 1); return; }
|
||||
|
||||
box.checked = true; box.dispatchEvent(new Event('change'));
|
||||
await sleep(400);
|
||||
const planTxt = (document.getElementById('rmPlan') || {}).textContent || '';
|
||||
say('清單有列出要刪的路徑', planTxt.includes('.arcrun-rag') && planTxt.includes('docs/.wiki'));
|
||||
say('清單有列出留著不動的', planTxt.includes('pms_v1_legacy'));
|
||||
say('清單有講幾個檔', planTxt.includes('52'));
|
||||
|
||||
document.getElementById('c2').click();
|
||||
await sleep(400);
|
||||
const call = window.__calls.find((c) => c[0] === 'RemoveFolder');
|
||||
say('有呼叫 RemoveFolder', !!call);
|
||||
say('第四個參數帶了 true', call ? call[4] === true : false);
|
||||
say('路徑正確', call ? call[2] === '/kb/pms' : false);
|
||||
say('done', 1);
|
||||
});
|
||||
JS
|
||||
|
||||
python3 - "$W" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1] + '/index.html'
|
||||
s = open(p, encoding='utf-8').read()
|
||||
s = s.replace('src="/assets/', 'src="./assets/').replace('href="/assets/', 'href="./assets/')
|
||||
s = s.replace('<script type="module"', '<script src="./mock.js"></script>\n<script type="module"', 1)
|
||||
open(p, 'w', encoding='utf-8').write(s)
|
||||
PY
|
||||
|
||||
(cd "$W" && python3 -m http.server 8798 >/dev/null 2>&1) &
|
||||
SRV_PID=$!
|
||||
sleep 2
|
||||
"$CHROME" --headless --disable-gpu --virtual-time-budget=8000 --dump-dom \
|
||||
"http://localhost:8798/index.html" 2>/dev/null > "$W/dom.html"
|
||||
|
||||
python3 - "$W/dom.html" <<'PY'
|
||||
import re, sys
|
||||
dom = open(sys.argv[1], encoding='utf-8').read()
|
||||
got = dict(re.findall(r'<div data-check="([^"]+)">([^<]*)</div>', dom))
|
||||
if not got:
|
||||
print(" ❌ 頁面沒跑到底(一個檢查點都沒寫出來)——看 dom.html"); sys.exit(1)
|
||||
bad = 0
|
||||
for k, v in got.items():
|
||||
if k == 'done':
|
||||
continue
|
||||
ok = v == 'true'
|
||||
print(f" {'✅' if ok else '❌'} {k}:{v}")
|
||||
bad += 0 if ok else 1
|
||||
if 'done' not in got:
|
||||
print(" ❌ 驅動腳本沒跑完"); sys.exit(1)
|
||||
sys.exit(1 if bad else 0)
|
||||
PY
|
||||
@@ -104,6 +104,10 @@ func (a *App) Connect(portalURL, email, password string) error {
|
||||
cfg.Accounts[i].APIKey = r.Config.Namespace
|
||||
cfg.Accounts[i].Email = r.Config.Email
|
||||
cfg.Accounts[i].InstanceName = r.Config.InstanceName
|
||||
// arcrun-rag#137:密碼**此刻**還在手上,順手換一張 portal session,
|
||||
// 之後 App 啟動器要看詳情/按動作就不必再問一次密碼。
|
||||
// 換不到不算連線失敗(同步這條主線用不到它),見 apps.go。
|
||||
tryStoreSessionFromLogin(cfg, i, r.Config.Email, password)
|
||||
return saveCfg(cfg)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +121,8 @@ func (a *App) Connect(portalURL, email, password string) error {
|
||||
Namespace: r.Config.Namespace,
|
||||
APIKey: r.Config.Namespace,
|
||||
})
|
||||
// arcrun-rag#137:同上,順手換一張 portal session 給 App 啟動器用。
|
||||
tryStoreSessionFromLogin(cfg, len(cfg.Accounts)-1, r.Config.Email, password)
|
||||
if wasFirstEver {
|
||||
// leo 2026-08-08:全新安裝自動裝一個「可刪除的預設庫」,讓使用者不必
|
||||
// 自己選資料夾也能立刻看到「丟檔 → 知識卡 → 搜得到」整條路(見 default_library.go)。
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// folder_badge.go — 資料夾那一列的**同步狀態圖示**(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// leo 2026-08-28 原話:
|
||||
//
|
||||
// 「補送中是什麼意思?**不要發明奇怪狀態**⋯⋯他要知道的是『**我的資料夾是否同步了**』,
|
||||
// **有同步打勾就好**」
|
||||
// 「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」
|
||||
//
|
||||
// ── 這支檔存在的理由 ─────────────────────────────────────────────────────
|
||||
// 在這之前,那一列的標籤是這樣算的(frontend/src/main.js:545):
|
||||
//
|
||||
// <span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
|
||||
//
|
||||
// 也就是說它量的**不是同步狀態**,是「#140 那句補送說明是不是空字串」。
|
||||
// 於是 2026-08-28 leo 的畫面上七列有六列寫「補送中」——連 `youlinhsieh-test1`
|
||||
// (實測 `pending=0`、`repaired=2`,補送早就做完了)也照樣標補送中。
|
||||
// ⇒ **標籤跟事實脫鉤**,而不是措辭不好聽。
|
||||
//
|
||||
// 現在改成從 `collector.SyncProgress` 算——那是 `(*Manifest).Progress()` 的原件,
|
||||
// 與首頁大數字、診斷檔用的是同一個函式。**畫面上打的勾,跟首頁的數字同源。**
|
||||
//
|
||||
// ── 三種狀態,沒有第四種 ─────────────────────────────────────────────────
|
||||
//
|
||||
// ✅ ok Done == Total 每一份我認得的檔都送進知識庫了,而且送上去之後沒再改過
|
||||
// 🔄 working 還沒送完,但沒有出錯的跡象
|
||||
// ⚠️ trouble 已經放棄自動重試(Stuck),或**一份都還沒成功而且每一份都失敗過**
|
||||
//
|
||||
// 🔴 第三種的第二個條件是 leo 那句「Geek6688 很久沒碰了也顯示補送中?」逼出來的。
|
||||
// 實查他機器上的 manifest(2026-08-28):`geek6688-test1` 的 14 份**每一份的
|
||||
// fail_count 都大於 0**(7 與 3),錯誤是雲端回 `HTTP 500 Node post_block failed:
|
||||
// Too many subrequests`,`done=0`。
|
||||
// ⇒ 這不是「排隊中」,是「一直在撞牆」。標成同步中=叫他等一件正在壞掉的事。
|
||||
// ⇒ 但**單看「有檔案在重試」不夠**:`pms` 有 110 份待處理、其中只有 4 份在重試、
|
||||
// 已經送成功 23 份——那是健康的積壓,標警告就變成新的噪音(實測 9 個資料夾裡
|
||||
// 會有 7 個亮警告,等於回到 leo 抱怨的「滿畫面都是狀態」)。
|
||||
// **所以第二個條件要求 `Done == 0`:一份都沒成功、而且全都在失敗,才叫撞牆。**
|
||||
//
|
||||
// 🔴 剛加進來、還沒開始送的資料夾(Failing == 0)落在 working,不會誤報警告。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// 資料夾狀態的機器代碼。前端只認這三個字串,圖示與顏色由 CSS 決定
|
||||
// ——狀態名不進畫面,畫面上只有一個圖示(GUI 的目的是讓人少讀字)。
|
||||
const (
|
||||
folderSyncOK = "ok"
|
||||
folderSyncWorking = "working"
|
||||
folderSyncTrouble = "trouble"
|
||||
// folderSyncUnknown=collector 還沒回報過這個資料夾(剛加進來、第一輪還沒跑完)。
|
||||
// **不能落到 ok**——那會是「還沒查就打勾」,正是這張票的紅線。
|
||||
folderSyncUnknown = "unknown"
|
||||
)
|
||||
|
||||
// folderBadge 把一個資料夾的同步現況翻成「一個狀態代碼+一句短提示」。
|
||||
//
|
||||
// known=status.json 裡有沒有這個資料夾的紀錄。沒有就是 unknown,不猜。
|
||||
//
|
||||
// 提示句刻意短:它住在 tooltip 裡,而 leo 的紅線是「**不要把長句子搬進 tooltip
|
||||
// 裡繼續長**」。要看細節的人有診斷檔(疑難排解那張卡),不是靠這一行。
|
||||
func folderBadge(p collector.SyncProgress, known bool) (state, tip string) {
|
||||
if !known {
|
||||
return folderSyncUnknown, "還在確認這個資料夾"
|
||||
}
|
||||
switch {
|
||||
case p.Total == 0:
|
||||
// 資料夾是空的、或裡面沒有我讀得了的檔。**不是「同步好了」**,
|
||||
// 所以不打勾——打勾要對應「東西真的在知識庫裡」。
|
||||
return folderSyncUnknown, "還沒有可整理的檔案"
|
||||
case p.Done == p.Total:
|
||||
return folderSyncOK, fmt.Sprintf("已同步 · %d 份", p.Done)
|
||||
case p.Stuck > 0:
|
||||
return folderSyncTrouble, fmt.Sprintf("%d 份一直送不上去", p.Stuck)
|
||||
case p.Done == 0 && p.Failing > 0:
|
||||
return folderSyncTrouble, fmt.Sprintf("%d 份都還沒送成功", p.Failing)
|
||||
default:
|
||||
return folderSyncWorking, fmt.Sprintf("同步中 · 還有 %d 份", p.Total-p.Done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// TestDumpBadgeFixture 拿**這台機器上真的 status.json**跑一次 folderBadge,
|
||||
// 把結果印出來/落成畫面驗收要用的 fixture(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// 🔴 為什麼不用手寫 fixture:驗收要貼的是「改後的實際畫面」,而畫面上的圖示
|
||||
// 必須是**真的那個函式**算出來的。手抄一份 fixture 就是第二套實作,
|
||||
// 貼出來的截圖也就不再是證據。
|
||||
//
|
||||
// 預設跳過(要真機資料才有意義):`DUMP_BADGE_FIXTURE=1 go test -run TestDumpBadgeFixture`
|
||||
func TestDumpBadgeFixture(t *testing.T) {
|
||||
if os.Getenv("DUMP_BADGE_FIXTURE") == "" {
|
||||
t.Skip("設 DUMP_BADGE_FIXTURE=1 才跑(要讀這台機器真的 status.json)")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
dir := filepath.Join(home, ".arcrun-rag")
|
||||
|
||||
var st struct {
|
||||
FolderProgress map[string]collector.SyncProgress `json:"folder_progress"`
|
||||
Resync map[string]collector.ResyncStatus `json:"resync"`
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "status.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// status.json 還沒有 folder_progress(那台跑的是舊版)⇒ 從 manifest 現算,
|
||||
// 用的仍是 (*Manifest).Progress() 本尊,不是另一套數法。
|
||||
if len(st.FolderProgress) == 0 {
|
||||
st.FolderProgress = map[string]collector.SyncProgress{}
|
||||
ms, _ := filepath.Glob(filepath.Join(dir, "manifest-official-*.json"))
|
||||
for _, mp := range ms {
|
||||
m, err := collector.LoadManifest(mp, "")
|
||||
if err != nil || m == nil || m.Root == "" {
|
||||
continue
|
||||
}
|
||||
st.FolderProgress[m.Root] = m.Progress()
|
||||
}
|
||||
}
|
||||
|
||||
roots := make([]string, 0, len(st.FolderProgress))
|
||||
for r := range st.FolderProgress {
|
||||
roots = append(roots, r)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
out := []map[string]any{}
|
||||
for _, r := range roots {
|
||||
p := st.FolderProgress[r]
|
||||
state, tip := folderBadge(p, true)
|
||||
fmt.Printf("%-56s total=%-5d done=%-5d pending=%-5d stuck=%-3d failing=%-4d → %-8s %s\n",
|
||||
filepath.Base(r), p.Total, p.Done, p.Pending, p.Stuck, p.Failing, state, tip)
|
||||
out = append(out, map[string]any{
|
||||
"path": r, "sync": state, "syncTip": tip,
|
||||
"resyncNote": st.Resync[r].Note,
|
||||
})
|
||||
}
|
||||
if dest := os.Getenv("BADGE_FIXTURE_OUT"); dest != "" {
|
||||
j, _ := json.MarshalIndent(out, "", " ")
|
||||
if err := os.WriteFile(dest, j, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("fixture 寫到 %s", dest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
// TestFolderBadge 守住「打勾是真的」這條紅線(`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// leo 的紅線原文:「打勾要是**真的**:說得出『打勾』的判準是什麼,
|
||||
// **不能是『沒有錯誤就打勾』**」。
|
||||
// ⇒ 下面每一格都在問同一件事:**這個狀態對應得上 manifest 裡的哪個事實?**
|
||||
func TestFolderBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
p collector.SyncProgress
|
||||
known bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// 打勾的唯一判準:認得的檔**每一份**都送上去了,而且送上去之後沒再改過
|
||||
// (Done 的定義見 progress.go:IngestedHash != "" && == ContentHash)。
|
||||
name: "全部送完才打勾",
|
||||
p: collector.SyncProgress{Total: 19, Done: 19},
|
||||
known: true,
|
||||
want: folderSyncOK,
|
||||
},
|
||||
{
|
||||
name: "差一份就不准打勾",
|
||||
p: collector.SyncProgress{Total: 19, Done: 18, Pending: 1},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 🔴 「沒有錯誤就打勾」的反例:沒有任何失敗,但也還沒送完。
|
||||
name: "零錯誤但沒送完=同步中,不是打勾",
|
||||
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// leo 2026-08-28 桌面實測值:geek6688-test1(14 份全在重試、一份都沒成功,
|
||||
// 雲端回 HTTP 500 Too many subrequests)。這是「不再標成進行中」的那一格。
|
||||
name: "一份都沒成功而且全都在重試=警告",
|
||||
p: collector.SyncProgress{Total: 14, Done: 0, Pending: 14, Failing: 14},
|
||||
known: true,
|
||||
want: folderSyncTrouble,
|
||||
},
|
||||
{
|
||||
// 對照組:健康的積壓不准亮警告,不然畫面又變回「滿滿都是狀態」。
|
||||
// 實測值取自 pms(110 待處理裡只有 4 份在重試,已經成功 23 份)。
|
||||
name: "有成功過的積壓=同步中,不亮警告",
|
||||
p: collector.SyncProgress{Total: 133, Done: 23, Pending: 110, Failing: 4},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 剛加進來、還沒開始送:一份都沒成功,但也還沒失敗過 ⇒ 不准嚇人。
|
||||
name: "剛加進來還沒送=同步中",
|
||||
p: collector.SyncProgress{Total: 20, Done: 0, Pending: 20},
|
||||
known: true,
|
||||
want: folderSyncWorking,
|
||||
},
|
||||
{
|
||||
// 已放棄自動重試=**不會自己好**,就算大多數都送成功了也要講。
|
||||
name: "有放棄重試的就亮警告",
|
||||
p: collector.SyncProgress{Total: 4184, Done: 817, Pending: 3342, Stuck: 25, Failing: 95},
|
||||
known: true,
|
||||
want: folderSyncTrouble,
|
||||
},
|
||||
{
|
||||
// 空資料夾不准打勾——打勾要對應「東西真的在知識庫裡」。
|
||||
name: "沒有可整理的檔案不打勾",
|
||||
p: collector.SyncProgress{},
|
||||
known: true,
|
||||
want: folderSyncUnknown,
|
||||
},
|
||||
{
|
||||
// 🔴 collector 還沒回報 ⇒ 零值。舊寫法會讓它看起來「什麼問題都沒有」,
|
||||
// 在這裡它必須是 unknown,不能落到 ok。
|
||||
name: "還沒回報過不准打勾",
|
||||
p: collector.SyncProgress{},
|
||||
known: false,
|
||||
want: folderSyncUnknown,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, tip := folderBadge(c.p, c.known)
|
||||
if got != c.want {
|
||||
t.Fatalf("folderBadge(%+v, known=%v) = %q,預期 %q", c.p, c.known, got, c.want)
|
||||
}
|
||||
if strings.TrimSpace(tip) == "" {
|
||||
t.Fatalf("每個狀態都要講得出自己是什麼(leo 驗收條件 3),但 tip 是空的")
|
||||
}
|
||||
// 紅線:提示句是**產品文案**,不准出現狀態碼/技術詞;也不准長成一段說明
|
||||
// (leo:「不要把長句子搬進 tooltip 裡繼續長」)。
|
||||
for _, bad := range []string{"HTTP", "500", "error", "token", "manifest", "pending", "stuck"} {
|
||||
if strings.Contains(strings.ToLower(tip), strings.ToLower(bad)) {
|
||||
t.Fatalf("提示句出現技術詞 %q:%q", bad, tip)
|
||||
}
|
||||
}
|
||||
if n := len([]rune(tip)); n > 20 {
|
||||
t.Fatalf("提示句 %d 字,太長了(上限 20):%q", n, tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,28 @@ $('themeBtn').onclick = () =>
|
||||
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
|
||||
let state = null;
|
||||
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
|
||||
// page:'apps'(App 啟動器)| 'app:<accIdx>:<id>' | 'home' | 'ai' | 'update' | 'lib:<idx>'
|
||||
//
|
||||
// 🔴 arcrun-rag#137:預設落在 App 啟動器,不是首頁——leo 2026-08-24 的原話是
|
||||
// 「**打開桌面小幫手就看到**跟 Portal 同一套的 App 啟動器」。
|
||||
// 同步狀態沒有因此消失:它在**全站頁首**(statusBig/statusSub+「立刻同步」),
|
||||
// 每一頁都看得到;首頁那些卡片仍在側欄的「首頁」裡,一鍵可達。
|
||||
// 還沒連任何知識庫時 render() 會把它改回 'home'(那裡才是連線精靈)。
|
||||
let page = 'apps';
|
||||
let updateInfo = null;
|
||||
let obStep = 1; // 首次啟動精靈目前在第幾步(issue #23,見 onboarding())
|
||||
|
||||
// ── App 啟動器的暫存(arcrun-rag#137)──
|
||||
//
|
||||
// 🔴 這是**畫面暫存,不是本機清單**:只活在這個視窗的記憶體裡,關掉就沒了,
|
||||
// 永遠不寫檔。上游 inkstone/Arcrun#82 已定「安裝態只有一份真相源」=實例上那一份,
|
||||
// 桌面端不准另存一份(本票紅線)。存在這裡只是為了不要每次換頁都重打一次網路。
|
||||
let appsAccIdx = 0; // 啟動器現在在看哪一個知識庫
|
||||
let appsCache = {}; // accIdx -> ListApps() 的回傳(undefined=還沒問,null=正在問)
|
||||
let appDetail = null; // 目前打開的那個 App 的詳情(GetApp() 的回傳)
|
||||
let appDetailKey = ''; // 'accIdx:id',避免慢回應蓋掉已經換過去的另一個 App
|
||||
let appFrameBridge = null; // App 自帶畫面那個 iframe 的 postMessage 監聽器(換頁時要拆掉)
|
||||
|
||||
// ── 覆蓋層(只給必須打斷的確認)──
|
||||
function openSheet(html, wire) { $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire(); }
|
||||
function closeSheet() { $('overlay').classList.remove('on'); }
|
||||
@@ -50,7 +68,21 @@ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet
|
||||
// 之後若那個提案 confirm,是在這層之上疊總覽 pill,不是重做這裡。
|
||||
function renderNav() {
|
||||
const accs = (state && state.accounts) || [];
|
||||
// arcrun-rag#137:「App」這一段在最上面,且**只有連了知識庫才出現**——
|
||||
// 沒有實例就沒有 App,把一個必定空的入口擺在第一項只會讓人以為壞了。
|
||||
// 目前打開的那個 App 以子項的形式掛在「App 界面」下面(同 Portal 的做法:
|
||||
// 已安裝的 App 是側欄的一格),這樣使用者知道自己在哪、也回得去。
|
||||
const appNav = !accs.length ? '' : `
|
||||
<div class="sec">App</div>
|
||||
<div class="nav" data-p="apps"><span class="ic">▦</span><span class="nm">App 界面</span></div>
|
||||
${page.startsWith('app:') && appDetail && !appDetail.error ? `
|
||||
<div class="nav" data-p="${esc(page)}" style="padding-left:44px">
|
||||
<span class="ic">${esc(appDetail.icon || '▢')}</span>
|
||||
<span class="nm">${esc(appDetail.name || appDetail.id)}</span>
|
||||
</div>` : ''}
|
||||
<div class="sec">小幫手</div>`;
|
||||
$('nav').innerHTML = `
|
||||
${appNav}
|
||||
<div class="nav" data-p="home"><span class="ic">◫</span><span class="nm">首頁</span></div>
|
||||
${accs.length ? `<div class="sec">知識庫</div>` : ''}
|
||||
${accs.map((a, i) => `
|
||||
@@ -64,7 +96,17 @@ function renderNav() {
|
||||
<div class="nav" data-p="update"><span class="ic">↧</span><span class="nm">版本與更新</span></div>`;
|
||||
$('nav').querySelectorAll('.nav').forEach((el) => {
|
||||
el.classList.toggle('on', el.dataset.p === page);
|
||||
el.onclick = () => { page = el.dataset.p; renderNav(); renderPage(); };
|
||||
el.onclick = () => {
|
||||
const p = el.dataset.p;
|
||||
if (p === page) return;
|
||||
// 離開 App 頁時把 iframe 的橋拆掉(見 goToApp 同一段理由)。
|
||||
if (page.startsWith('app:') && appFrameBridge) {
|
||||
window.removeEventListener('message', appFrameBridge); appFrameBridge = null;
|
||||
}
|
||||
page = p;
|
||||
renderNav(); renderPage();
|
||||
if (p === 'apps') loadApps(appsAccIdx);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,25 +166,39 @@ function cardKbVersions(s) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// kbVersionLine:單一知識庫的版本判定文案,首頁卡與各庫頁共用同一份翻譯,
|
||||
// 不讓兩處各寫各的文字(那樣才真的會出現「兩處講不同的話」)。
|
||||
// kbVersionLine:單一知識庫的版本,首頁卡與各庫頁共用同一份(不讓兩處各寫各的)。
|
||||
//
|
||||
// 三種情況都要照實講(cloud_version.go 記過的坑:把「查不到」呈現成「一切正常」):
|
||||
// ① Known 且落後 → 紅字+按鈕,按下去帶 email 開 install 頁(同 portal 版本卡的預填做法)
|
||||
// ② Known 且已最新 → 淡字「已是最新版」
|
||||
// ③ 不 Known → 照原因分兩句:連不上這個知識庫/查得到目前版本但暫時查不到最新版
|
||||
// 🔴 `inkstone/arcrun-rag#159`(leo 2026-08-28):「**GUI 是國際通用,圖形指示,
|
||||
// 不限語文**,所以任何出現文字都要謹慎,文字表示『我的設計不夠好所以要靠文字來補』」
|
||||
// ⇒ 這一行以前是三句話(「有新版可更新(目前 x → 最新 y)」「已是最新版(x)」
|
||||
// 「目前連不上這個知識庫,查不到版本」)。現在只剩**版本號與圖示**:
|
||||
//
|
||||
// 1.4.60 ✅ 已是最新
|
||||
// 1.4.46 → 1.4.58 ⬆️ 有新版(按鈕,按下去帶 email 開安裝頁)
|
||||
// ○ 從來沒查到過這台的版本
|
||||
//
|
||||
// 說明都收進 title(滑過去才出現),版面上一個字都不佔。
|
||||
//
|
||||
// 🔴 「這一輪連不上」**不再抹掉版本號**(見 collector/direct.go 那段註解):
|
||||
// 版本查到過就是事實,抽風的是網路。連不上時版本號調淡,滑過去講得出來。
|
||||
function kbVersionLine(a) {
|
||||
const dim = a.cloudVerFresh ? '' : ' dim';
|
||||
const tip = a.cloudVerFresh ? '' : '上次查到的版本(現在暫時連不上)';
|
||||
if (!a.cloudVerKnown) {
|
||||
const detail = a.cloudVerMine
|
||||
? `已知版本 ${esc(a.cloudVerMine)},暫時查不到最新版本(稍後會自動再查)`
|
||||
: '目前連不上這個知識庫,查不到版本';
|
||||
return `<span class="d">${detail}</span>`;
|
||||
// 分得出「沒查到這台的版本」與「查到了但暫時不知道最新版是多少」——
|
||||
// 前者連版本號都沒有,後者有版本號可以顯示。
|
||||
if (!a.cloudVerMine) {
|
||||
return `<span class="kbv unknown" role="img" title="還沒查到這個知識庫的版本">○</span>`;
|
||||
}
|
||||
return `<span class="kbv${dim}" title="${esc(tip || '暫時查不到最新版本,稍後自動再查')}">${esc(a.cloudVerMine)}</span>`;
|
||||
}
|
||||
if (a.cloudVerStale) {
|
||||
return `<span class="warn">有新版可更新(目前 ${esc(a.cloudVerMine)} → 最新 ${esc(a.cloudVerLatest)})</span>
|
||||
<button class="ghost" data-updatekb="${esc(a.email || '')}">前往安裝頁更新</button>`;
|
||||
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)} → ${esc(a.cloudVerLatest)}</span>
|
||||
<button class="ico" data-updatekb="${esc(a.email || '')}"
|
||||
title="前往安裝頁更新這個知識庫" aria-label="前往安裝頁更新這個知識庫">⬆️</button>`;
|
||||
}
|
||||
return `<span class="d">已是最新版(${esc(a.cloudVerMine)})</span>`;
|
||||
return `<span class="kbv${dim}" title="${esc(tip)}">${esc(a.cloudVerMine)}</span>
|
||||
<span class="fstat ok" role="img" title="已是最新版" aria-label="已是最新版">✅</span>`;
|
||||
}
|
||||
|
||||
// installURLFor:與 portal 版本卡同一個做法——落後才需要按,按下去帶 email 讓安裝頁
|
||||
@@ -254,6 +310,258 @@ function cardSkipped(k) {
|
||||
}
|
||||
|
||||
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半,leo 2026-08-26)
|
||||
//
|
||||
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
|
||||
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
|
||||
// 沒同步的那幾份為什麼沒上去。」Portal 那半已經在跑,這裡是桌面這半。
|
||||
//
|
||||
// 🔴 形狀規格=`inkstone/Arcrun#144`(leo 2026-08-19 驗 1.4.49 之後打回 markmap):
|
||||
// 「我的需求是**向右向下**,類似 terminal 的 tree,**一列一列向下往後退縮**,
|
||||
// 緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**,這是一般辦公室用戶
|
||||
// 都有的經驗,且可以用滑鼠輕易操作,不會佔用大面積。」
|
||||
// ⇒ 一列一行、縮排、可摺疊、字級行距貼近作業系統的檔案總管。
|
||||
// ⇒ **不用任何樹狀圖套件**:這個形狀就是「縮排的清單」,一個 div 一列就到位;
|
||||
// 引一包 library 進來只會多一份要跟著 CIS 對齊的樣式來源。
|
||||
//
|
||||
// 🔴 **一個判準都不在這裡發明**(同 portal 那半的紅線):兩個數字與「為什麼沒收」
|
||||
// 的分類全部由 collector 算好(`collector/foldertree.go`,判準活在 scan.go 那趟
|
||||
// WalkDir)。這裡只做兩件事:把節點串成樹、把子樹的數字加起來。
|
||||
// 哪天想在這裡寫「.py 算不算支援」——停手,那是第二份實作。
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// data:path → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
|
||||
// open:path → 這個資料夾的樹展開了沒
|
||||
// nodes:path → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen)
|
||||
// why:path → { 節點路徑: 這一列的「為什麼」被點開了沒 }(arcrun-rag#159)
|
||||
const treeState = { data: {}, open: {}, nodes: {}, why: {} };
|
||||
|
||||
// ── 資料夾那一列的狀態圖示(`inkstone/arcrun-rag#159`)────────────────────
|
||||
//
|
||||
// leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
|
||||
// 畫面上都是字壓力很大**」「他要知道的是『我的資料夾是否同步了』,**有同步打勾就好**」
|
||||
//
|
||||
// 🔴 這裡**只做代碼→圖示的對照**,一個判斷都不做。
|
||||
// 「該不該打勾」住在 Go 那一側(cmd/arcrun-app/folder_badge.go,有測試守著)——
|
||||
// 前端自己判斷就會變成第二套判準,遲早跟後端說的不一樣。
|
||||
const SYNC_ICON = { ok: '✅', working: '🔄', trouble: '⚠️', unknown: '○' };
|
||||
|
||||
// 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。
|
||||
function treeBoxId(path) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < path.length; i++) h = (h * 31 + path.charCodeAt(i)) | 0;
|
||||
return 'ft' + (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
// 子樹合計:小幫手送的每個數字都只算「這一層直接放的檔」(存兩套遲早對不起來),
|
||||
// 所以要顯示「這個資料夾底下總共」就得在這裡疊一次。
|
||||
// 🔴 **沒走進去**的節點整棵不計:不知道裡面有幾個檔,加 0 會讓分母說謊。
|
||||
// 分辨方法(inkstone/Arcrun#180 之後):`skipped` 已經改成「這一層的檔不收」,
|
||||
// 而**走進去過的節點 total_files 是真的**——所以只有 `total_files === 0` 的
|
||||
// skipped 節點才是「真的沒走進去」,那種才不計。
|
||||
// 走進去過的(例如 `system-dev/wiki` 6 個範本檔全排除)要照算,
|
||||
// 不然 leo 要的「知道資料夾下有幾個檔案被萃」在合計上又變回 0。
|
||||
// (與 portal 的 rollupTree 同一套算法——兩邊看到的數字必須是同一個。)
|
||||
function rollupTree(nodes) {
|
||||
const byPath = {}, kids = {};
|
||||
nodes.forEach((n) => { byPath[n.path] = n; (kids[n.parent] = kids[n.parent] || []).push(n); });
|
||||
const sums = {};
|
||||
function walk(n) {
|
||||
const acc = { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
|
||||
if (!n.skipped || n.total_files > 0) {
|
||||
acc.total = n.total_files; acc.synced = n.synced_files; acc.pending = n.pending_files;
|
||||
acc.unsupported = n.unsupported_files; acc.excluded = n.excluded_files;
|
||||
}
|
||||
(kids[n.path] || []).forEach((c) => {
|
||||
const x = walk(c);
|
||||
acc.total += x.total; acc.synced += x.synced; acc.pending += x.pending;
|
||||
acc.unsupported += x.unsupported; acc.excluded += x.excluded;
|
||||
});
|
||||
sums[n.path] = acc;
|
||||
return acc;
|
||||
}
|
||||
// 根=parent 是 '-'(collector/foldertree.go 刻意用它,才分得出「我是根」與「父親是根」)
|
||||
(kids['-'] || []).forEach(walk);
|
||||
// 孤兒節點(父親不在這份清單裡,例如樹被截斷)也要走一次,否則它整棵不會被算到
|
||||
nodes.forEach((n) => { if (sums[n.path] === undefined && !byPath[n.parent]) walk(n); });
|
||||
return { sums, kids, byPath };
|
||||
}
|
||||
|
||||
// 差額必須解釋得了:總數 − 已同步 = 不支援 + 不收 + 處理中。
|
||||
// 這行文案的存在理由就是 leo 那句「不上傳通常是不支援,比如程式碼、不支援的格式」
|
||||
// ——畫面要自己回答,不必問人。
|
||||
function gapWhy(s) {
|
||||
const parts = [];
|
||||
if (s.unsupported > 0) parts.push(s.unsupported + ' 份格式還讀不了');
|
||||
if (s.excluded > 0) parts.push(s.excluded + ' 份不在收檔範圍(程式碼等)');
|
||||
if (s.pending > 0) parts.push(s.pending + ' 份處理中');
|
||||
return parts.join('・');
|
||||
}
|
||||
|
||||
// 預設只展開根那一層——**照檔案總管的行為**:打開一個資料夾看到它底下一層,
|
||||
// 要更深自己點。一次全攤開在 8000 個檔的資料夾上就是一面沒人看得完的牆。
|
||||
function nodeIsOpen(path, n) {
|
||||
const st = treeState.nodes[path] || {};
|
||||
return st[n.path] === undefined ? n.depth === 0 : st[n.path];
|
||||
}
|
||||
|
||||
function renderFolderTree(path) {
|
||||
const box = $(treeBoxId(path));
|
||||
if (!box) return;
|
||||
const tree = treeState.data[path];
|
||||
if (tree === undefined) { box.innerHTML = `<div class="ftmsg">讀取中…</div>`; return; }
|
||||
// 🔴 分得出「還沒掃到」與「掃過、裡面是空的」——後者是 arcrun-rag#106 的正常情況
|
||||
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
|
||||
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
|
||||
if (tree === null) {
|
||||
box.innerHTML = `<div class="ftmsg">還沒掃到這個資料夾,第一次同步跑完就會出現。</div>`;
|
||||
return;
|
||||
}
|
||||
const nodes = tree.nodes || [];
|
||||
if (!nodes.length) {
|
||||
box.innerHTML = `<div class="ftmsg">這個資料夾目前是空的。</div>`;
|
||||
return;
|
||||
}
|
||||
const r = rollupTree(nodes);
|
||||
let html = '';
|
||||
function emit(n) {
|
||||
const s = r.sums[n.path] || { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
|
||||
const kids = (r.kids[n.path] || []).slice().sort((a, b) => (a.path < b.path ? -1 : 1));
|
||||
const open = kids.length ? nodeIsOpen(path, n) : false;
|
||||
// 縮排 16px 一層=檔案總管的量級;一列一行、往後退縮(Arcrun#144 的形狀)。
|
||||
const indent = 4 + n.depth * 16;
|
||||
// 🔴 三角形用 CSS 畫(`<i>` 那個空元素),**不用 ▸▾ 字元**:
|
||||
// 那兩個字在 Windows 的中文字型裡會被當成全形符號、大小與位置各機器不同,
|
||||
// 而它正是 leo 2026-08-19 點名「展開字很小、滑鼠不好按」的那個東西。
|
||||
// 畫出來的三角形每一台都一樣大,也才控得住點擊區。
|
||||
// 可展開的那幾列是**按鈕**,不是裝飾用的 div:標上 role/tabindex/aria-expanded
|
||||
// ⇒ 鍵盤按得到、輔助技術念得出「收合/展開」,機械檢查也看得見它是可操作的。
|
||||
let row = `<div class="ftrow${kids.length ? ' has' : ''}${open ? ' open' : ''}" style="padding-left:${indent}px"`
|
||||
+ (kids.length
|
||||
? ` data-tnode="${esc(n.path)}" data-troot="${esc(path)}" role="button" tabindex="0"`
|
||||
+ ` aria-expanded="${open}" title="${open ? '收合' : '展開'}「${esc(n.name || '')}」"`
|
||||
: '') + `>`
|
||||
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
|
||||
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
|
||||
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
|
||||
// ── 一列只有:三角形 + 資料夾名 + `X / Y`(`inkstone/arcrun-rag#159`)──
|
||||
//
|
||||
// leo 2026-08-28:「**上下廢話刪除**,**產生 GUI 就是要讓人減少讀字降低負擔,
|
||||
// 你在 GUI 寫這麼多字剛好違背它的原理**」——他的截圖上同一句
|
||||
// 「這是安裝範本時鋪下來的空白樣板…」**在一棵樹裡出現了 5 次**。
|
||||
//
|
||||
// 🔴 **「收起來」不等於「刪掉」**(本票紅線):那些理由是使用者要救回被跳過的
|
||||
// 資料夾時的線索(`inkstone/arcrun-rag#136`)。所以理由沒有被拿掉,
|
||||
// 只是**不再預設佔畫面**——點那個數字就展開這一列自己的理由。
|
||||
// 🔴 也**不塞進 tooltip**(同一條紅線:「不要把長句子搬進 tooltip 裡繼續長」),
|
||||
// tooltip 只有「點一下看原因」這種操作提示。
|
||||
let why = n.skipped
|
||||
? (n.skip_reason || '(小幫手沒說明理由)')
|
||||
: gapWhy(s);
|
||||
// 收檔策略那句話(原本掛在樹的上方,leo 圈掉了)改掛在**根那一列**——
|
||||
// 它講的就是這個監看根,點根的數字就看得到,資訊沒有消失。
|
||||
if (n.parent === '-' && tree.reason) why = why ? `${tree.reason}(${why})` : tree.reason;
|
||||
// 整棵沒走進去(#180 之後:skipped 且 total_files 為 0)⇒ 不准顯示 0/0,
|
||||
// 那會是我們自己編的數字。用「—」表示「這個數字我沒有」。
|
||||
const noCount = n.skipped && n.total_files === 0;
|
||||
const full = !noCount && s.total > 0 && s.synced === s.total;
|
||||
const shown = noCount ? '—' : `${s.synced} / ${s.total}`;
|
||||
row += `<span class="num${full ? ' full' : ''}${why ? ' hasWhy' : ''}"`
|
||||
+ (why
|
||||
? ` data-twhy="${esc(n.path)}" data-twroot="${esc(path)}" role="button" tabindex="0" title="點一下看原因"`
|
||||
: '')
|
||||
+ `>${shown}</span>`;
|
||||
html += row + `</div>`;
|
||||
if (why && (treeState.why[path] || {})[n.path]) {
|
||||
html += `<div class="ftwhy" style="padding-left:${indent + 21}px">${esc(why)}</div>`;
|
||||
}
|
||||
if (open) kids.forEach(emit);
|
||||
}
|
||||
(r.kids['-'] || []).forEach(emit);
|
||||
// 父親不在清單裡的孤兒(樹被截斷時會有)——照樣列出來,缺角要看得見,不要偷偷藏起來
|
||||
const seen = {};
|
||||
nodes.forEach((n) => { seen[n.path] = true; });
|
||||
nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); });
|
||||
|
||||
// 🔴 樹的上方與下方**什麼都不留**(`inkstone/arcrun-rag#159`,leo 在截圖上圈了三處):
|
||||
// ① 上方的「全部展開/全部收合 + 這是一般資料夾或筆記庫…」整條拿掉
|
||||
// ② 下方的「數字是『已同步 / 這個資料夾底下總共』…」整段拿掉
|
||||
// ③ 下方的補送說明拿掉(狀態改由資料夾那一列的圖示表達)
|
||||
// leo 原話:「**上下廢話刪除,只留乾淨 Tree**」。
|
||||
//
|
||||
// 收檔策略那句話(tree.reason)沒有消失——它跟每一列的理由走同一條路:
|
||||
// 點根那一列的數字就看得到(emit() 裡的 hasWhy)。
|
||||
//
|
||||
// **唯一的例外是截斷警告**:它只在樹真的不完整時才出現,而不講就是讓畫面
|
||||
// 宣稱「這是全部」——那是說謊,不是廢話。
|
||||
let foot = '';
|
||||
if (tree.truncated) {
|
||||
foot = `<div class="ftmsg">只顯示前 ${nodes.length} 個資料夾(實際有 ${tree.total_nodes} 個)。</div>`;
|
||||
}
|
||||
box.innerHTML = `<div class="ftbody">${html}</div>${foot}`;
|
||||
wireTree();
|
||||
}
|
||||
|
||||
// 展開/收合某個資料夾的樹。第一次展開才去讀(同 portal 的 lazy 做法),
|
||||
// 之後留在記憶體裡——它是**畫面暫存不是第二份真相源**,關掉視窗就沒了。
|
||||
async function toggleFolderTree(path) {
|
||||
treeState.open[path] = !treeState.open[path];
|
||||
const box = $(treeBoxId(path));
|
||||
// #159:那顆按鈕現在只有一個 CSS 畫的三角形(沒有文字),轉向由 class 決定。
|
||||
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', String(!!treeState.open[path]));
|
||||
const row = btn.closest('.folder');
|
||||
if (row) row.classList.toggle('open', !!treeState.open[path]);
|
||||
}
|
||||
if (!box) return;
|
||||
box.style.display = treeState.open[path] ? '' : 'none';
|
||||
if (!treeState.open[path]) return;
|
||||
if (treeState.data[path] !== undefined) { renderFolderTree(path); return; }
|
||||
renderFolderTree(path); // 先畫「讀取中…」
|
||||
try {
|
||||
const t = await go.GetFolderTree(path);
|
||||
treeState.data[path] = t || null; // 後端回 null=還沒回報過
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="err">讀不到這個資料夾的結構:${esc(String(e))}</div>`;
|
||||
return;
|
||||
}
|
||||
renderFolderTree(path);
|
||||
}
|
||||
|
||||
// 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。
|
||||
function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); }
|
||||
|
||||
function wireTree() {
|
||||
document.querySelectorAll('[data-tnode]').forEach((el) => {
|
||||
const toggle = () => {
|
||||
const root = el.dataset.troot, np = el.dataset.tnode;
|
||||
const st = treeState.nodes[root] || (treeState.nodes[root] = {});
|
||||
const tree = treeState.data[root] || { nodes: [] };
|
||||
const n = (tree.nodes || []).find((x) => x.path === np);
|
||||
st[np] = !(st[np] === undefined ? (n && n.depth === 0) : st[np]);
|
||||
renderFolderTree(root);
|
||||
};
|
||||
el.onclick = toggle;
|
||||
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
|
||||
});
|
||||
// #159:點數字=展開這一列自己的「為什麼」。理由沒有被刪掉,只是不再預設佔畫面。
|
||||
// stopPropagation:這個數字常常長在「整列可點=展開子資料夾」的列上,
|
||||
// 不擋住的話點原因會順便把子資料夾收掉。
|
||||
document.querySelectorAll('[data-twhy]').forEach((el) => {
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
const root = el.dataset.twroot, np = el.dataset.twhy;
|
||||
const st = treeState.why[root] || (treeState.why[root] = {});
|
||||
st[np] = !st[np];
|
||||
renderFolderTree(root);
|
||||
};
|
||||
el.onclick = toggle;
|
||||
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } };
|
||||
});
|
||||
}
|
||||
|
||||
function pageLib(s, idx) {
|
||||
const a = s.accounts[idx];
|
||||
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
|
||||
@@ -276,11 +584,17 @@ function pageLib(s, idx) {
|
||||
: `正在從雲端收回…${f.retireRemaining ? `還有 ${f.retireRemaining} 份` : ''}`}</span>
|
||||
</div>
|
||||
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
|
||||
<div class="folder">
|
||||
<div class="folder${treeState.open[f.path] ? ' open' : ''}">
|
||||
<button class="tw" data-tree="${esc(f.path)}" aria-expanded="${!!treeState.open[f.path]}"
|
||||
title="展開這個資料夾" aria-label="展開或收合這個資料夾的內容"><i></i></button>
|
||||
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
|
||||
<span class="tag">自動同步中</span>
|
||||
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
|
||||
</div>`).join('')
|
||||
<span class="fstat ${esc(f.sync || 'unknown')}" role="img"
|
||||
title="${esc(f.syncTip || '')}" aria-label="${esc(f.syncTip || '')}"
|
||||
>${SYNC_ICON[f.sync] || SYNC_ICON.unknown}</span>
|
||||
<button class="ico" data-rm="${esc(f.path)}" data-acc="${f.accIdx}"
|
||||
title="移除這個資料夾" aria-label="移除這個資料夾並從知識庫收回">🗑</button>
|
||||
</div>
|
||||
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>`).join('')
|
||||
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
|
||||
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
|
||||
}
|
||||
@@ -411,10 +725,260 @@ function onboarding() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// App 啟動器(arcrun-rag#137)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的九宮格啟動界面,
|
||||
// 每個 App 有一個 icon,**這會運行在 portal 及 daemon**。」
|
||||
//
|
||||
// 🔴 清單一律問實例,桌面端沒有任何寫死或存檔的 App 名單(本票紅線;
|
||||
// 上游 inkstone/Arcrun#82「安裝態只有一份真相源」)。後端兩條取得路徑
|
||||
// 與為什麼是兩條,全寫在 collector/cmd/arcrun-app/apps.go 的檔頭。
|
||||
//
|
||||
// 🔴 **不掛在每秒的 tick 上**:只有「打開啟動器」「按重新整理」「切知識庫」
|
||||
// 這三個使用者動作會真的去問實例一次。把它塞進 tick 等於自造輪詢器。
|
||||
|
||||
// loadApps 去問某個知識庫裝了哪些 App;問完只在「使用者還停在啟動器」時重畫。
|
||||
async function loadApps(accIdx, force) {
|
||||
if (!force && appsCache[accIdx] !== undefined) return;
|
||||
appsCache[accIdx] = null; // null=問中(畫面顯示「查詢中」)
|
||||
if (page === 'apps') renderPage();
|
||||
let res;
|
||||
try {
|
||||
res = await go.ListApps(accIdx);
|
||||
} catch (ex) {
|
||||
res = { accIdx, apps: [], error: String(ex) };
|
||||
}
|
||||
appsCache[accIdx] = res;
|
||||
if (page === 'apps') renderPage();
|
||||
}
|
||||
|
||||
function pageApps(s) {
|
||||
const accs = s.accounts || [];
|
||||
if (!accs.length) {
|
||||
// 「沒連任何實例時要說人話」(驗收條件 1)——不是空白,也不是壞掉的樣子。
|
||||
return `<div class="empty">
|
||||
<div class="t">還沒有連上知識庫</div>
|
||||
<div class="d">App 住在你的知識庫上,連上之後這裡就會列出它裝了哪些 App。</div>
|
||||
<button class="primary" id="apConnect">連上知識庫</button>
|
||||
</div>`;
|
||||
}
|
||||
if (appsAccIdx >= accs.length) appsAccIdx = 0;
|
||||
const acc = accs[appsAccIdx];
|
||||
const r = appsCache[appsAccIdx];
|
||||
|
||||
// 知識庫切換器:只有一個庫時不畫(一顆永遠只能按自己的按鈕是純噪音)。
|
||||
const switcher = accs.length > 1 ? `
|
||||
<div class="appswitch">
|
||||
${accs.map((a, i) => `<span class="chip ${i === appsAccIdx ? 'on' : ''}" data-appacc="${i}">${esc(a.name)}</span>`).join('')}
|
||||
</div>` : '';
|
||||
|
||||
let sub = '查詢中…';
|
||||
let body = `<div class="card"><div class="d">正在問「${esc(acc.name)}」裝了哪些 App…</div></div>`;
|
||||
|
||||
if (r) {
|
||||
if (r.error) {
|
||||
// 🔴 「問不到」與「一個都沒裝」是兩件事,畫面上必須分得出來
|
||||
// (使用者該做的事完全相反:一個是修連線,一個是去裝 App)。
|
||||
sub = '這次沒問到';
|
||||
body = `<div class="card">
|
||||
<h3>暫時看不到這個知識庫的 App</h3>
|
||||
<div class="d">${esc(r.error)}</div>
|
||||
<div class="acts"><button id="apRetry">再試一次</button></div>
|
||||
</div>`;
|
||||
} else {
|
||||
const apps = r.apps || [];
|
||||
sub = `${apps.length} 個 App 已安裝`;
|
||||
body = `<div class="appgrid">
|
||||
${apps.map((a) => `
|
||||
<div class="appcell">
|
||||
<div class="apptile" data-appopen="${esc(a.id)}" title="${esc(a.name)}${a.version ? ' · v' + esc(a.version) : ''}">${esc(a.icon || '▢')}</div>
|
||||
<div class="nm" title="${esc(a.name)}">${esc(a.name)}</div>
|
||||
</div>`).join('')}
|
||||
<div class="appcell">
|
||||
<div class="apptile add" id="apAdd" title="怎麼加裝 App">+</div>
|
||||
<div class="nm dim">加裝 App</div>
|
||||
</div>
|
||||
</div>
|
||||
${apps.length ? '' : `<div class="card" style="margin-top:26px">
|
||||
<h3>這個知識庫還沒有 App</h3>
|
||||
<div class="d">App 是裝在知識庫上的:跟你的 AI 說一句「幫我裝一個 X」,
|
||||
或用 <b>acr</b> 推一份 App 宣告上去。裝好之後回到這裡按「重新整理」就會出現。</div>
|
||||
</div>`}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="apphead">
|
||||
<div class="g">
|
||||
<div class="t">App 界面</div>
|
||||
<div class="s">${esc(acc.name)} · ${esc(sub)}</div>
|
||||
</div>
|
||||
<button id="apRefresh">重新整理</button>
|
||||
</div>
|
||||
${switcher}
|
||||
${body}`;
|
||||
}
|
||||
|
||||
// ── 單一 App 的頁 ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAppDetail(accIdx, id) {
|
||||
const key = accIdx + ':' + id;
|
||||
appDetailKey = key;
|
||||
appDetail = null;
|
||||
renderPage();
|
||||
let d;
|
||||
try {
|
||||
d = await go.GetApp(accIdx, id);
|
||||
} catch (ex) {
|
||||
d = { id, error: String(ex) };
|
||||
}
|
||||
if (appDetailKey !== key) return; // 使用者已經換去別的 App 了,這份回應作廢
|
||||
appDetail = d;
|
||||
renderNav(); // 側欄的 App 子項要顯示名字與 icon
|
||||
renderPage();
|
||||
}
|
||||
|
||||
function pageApp(accIdx, id) {
|
||||
const d = appDetail;
|
||||
const head = (ico, nm, ver) => `
|
||||
<div class="head">
|
||||
<span class="ico">${esc(ico || '▢')}</span>
|
||||
<span class="nm">${esc(nm || id)}</span>
|
||||
${ver ? `<span class="vr">v${esc(ver)}</span>` : ''}
|
||||
<span class="sp"></span>
|
||||
<button data-appback="1">‹ 回 App 界面</button>
|
||||
</div>`;
|
||||
|
||||
if (!d) return `<div class="appview">${head('', id, '')}<div class="card"><div class="d">載入中…</div></div></div>`;
|
||||
|
||||
if (d.needsLogin) {
|
||||
// session 過期/這台機器還沒換過 session。不是錯誤,是「還差一步」。
|
||||
return `<div class="appview">${head(d.icon, d.name, d.version)}
|
||||
<div class="card">
|
||||
<h3>請先登入這個知識庫</h3>
|
||||
<div class="d">要打開 App 的畫面、或執行它的動作,需要你在這個知識庫的帳號登入一次
|
||||
(之後這台電腦會記住一段時間,同步不受影響)。</div>
|
||||
<div class="field"><div class="lb">帳號</div>
|
||||
<input type="text" id="apEmail" value="${esc(d.email || '')}" disabled/></div>
|
||||
<div class="field"><div class="lb">密碼</div><input type="password" id="apPw"/></div>
|
||||
<div class="err" id="apErr" style="display:none"></div>
|
||||
<div class="acts"><button class="primary" id="apLogin">登入</button></div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
if (d.error) {
|
||||
return `<div class="appview">${head(d.icon, d.name, d.version)}
|
||||
<div class="card">
|
||||
<h3>打不開這個 App</h3>
|
||||
<div class="d">${esc(d.error)}</div>
|
||||
<div class="acts"><button id="apReload">再試一次</button></div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
if (d.hasUi && d.uiHtml) {
|
||||
// 自帶畫面 ⇒ 掛進 sandbox iframe(mountAppUI 會在 wire() 之後填內容)。
|
||||
return `<div class="appview">${head(d.icon, d.name, d.version)}
|
||||
<iframe class="appframe" id="appFrame" sandbox="allow-scripts"></iframe></div>`;
|
||||
}
|
||||
|
||||
// 沒有自帶畫面 ⇒ 列出工作流,一條一顆「現在執行」
|
||||
//(與 Portal 的系統預設畫面同一套,不另立第二種呈現)。
|
||||
const wfs = d.workflows || [];
|
||||
if (!wfs.length) {
|
||||
return `<div class="appview">${head(d.icon, d.name, d.version)}
|
||||
<div class="card"><h3>這個 App 沒有可以按的東西</h3>
|
||||
<div class="d">它既沒有自己的畫面,也沒有登記任何工作流。</div></div></div>`;
|
||||
}
|
||||
return `<div class="appview">${head(d.icon, d.name, d.version)}
|
||||
${wfs.map((w) => `
|
||||
<div class="wfitem" data-wf="${esc(w.name)}">
|
||||
<div class="top">
|
||||
<span class="nm">${esc(w.name)}</span>
|
||||
<button data-apprun="${esc(w.name)}">現在執行</button>
|
||||
</div>
|
||||
${w.description ? `<div class="d">${esc(w.description)}</div>` : ''}
|
||||
<div class="out"></div>
|
||||
</div>`).join('')}`;
|
||||
}
|
||||
|
||||
// mountAppUI 把 App 自帶的 HTML 放進 **sandbox iframe**。
|
||||
//
|
||||
// 🔴 為什麼一定要 iframe(這是桌面端與 Portal 的關鍵差異,不是潔癖):
|
||||
// Portal 是網頁,把 App 的 HTML 直接 innerHTML 進去,那段 script 最多拿到
|
||||
// 同一頁的 fetch 與 session token。**桌面這半不一樣**——這裡的 window 上掛著
|
||||
// `window.go.main.App`:`Connect`/`SetAI`/`RemoveFolder(…, takedown=true, cleanupLocal=true)`
|
||||
// 全都在上面。直接 innerHTML = 任何一個 App 的作者都能刪掉使用者雲端的知識
|
||||
// ——#138 之後**連他硬碟上的整理稿也刪得掉**,這道窄門只會越來越重要。
|
||||
// ⇒ `sandbox="allow-scripts"`(**不給** allow-same-origin ⇒ 不同源,
|
||||
// 碰不到 parent 的任何東西),只留一條 postMessage 的窄門。
|
||||
//
|
||||
// 🔴 窄門的形狀刻意與 Portal 一致:App 作者一樣只認得
|
||||
// `window.arcrunApp.action(action, payload)`,回一個 `{ok,status,d}`——
|
||||
// 這樣同一個 App 的畫面在 Portal 與桌面上都跑得起來,作者不必寫兩份。
|
||||
function mountAppUI(accIdx, d) {
|
||||
const f = $('appFrame');
|
||||
if (!f) return;
|
||||
const bridge = `
|
||||
<script>
|
||||
(function () {
|
||||
var seq = 0, pending = {};
|
||||
window.addEventListener('message', function (e) {
|
||||
var m = e.data;
|
||||
if (!m || m.__arcrun !== 'result') return;
|
||||
var p = pending[m.id]; if (!p) return; delete pending[m.id];
|
||||
p(m.payload);
|
||||
});
|
||||
window.arcrunApp = {
|
||||
action: function (action, payload) {
|
||||
return new Promise(function (resolve) {
|
||||
var id = ++seq; pending[id] = resolve;
|
||||
parent.postMessage({ __arcrun: 'action', id: id, action: action, payload: payload || {} }, '*');
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
<\/script>`;
|
||||
// 讓 App 的畫面跟本體同一套底色/字體(它是 Arcrun 的一部分,不是外站)。
|
||||
const skin = `<style>
|
||||
:root{color-scheme:${document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'}}
|
||||
html,body{margin:0;padding:16px;background:transparent;
|
||||
color:${getComputedStyle(document.documentElement).getPropertyValue('--ink').trim() || '#17181A'};
|
||||
font-family:-apple-system,"IBM Plex Sans","PingFang TC","Noto Sans TC","Microsoft JhengHei",system-ui,sans-serif;
|
||||
font-size:15px}
|
||||
</style>`;
|
||||
f.srcdoc = '<!doctype html><meta charset="utf-8">' + skin + bridge + d.uiHtml;
|
||||
f.onload = () => {
|
||||
if (appFrameBridge) window.removeEventListener('message', appFrameBridge);
|
||||
appFrameBridge = async (ev) => {
|
||||
if (!f.contentWindow || ev.source !== f.contentWindow) return; // 只認自己這個 iframe
|
||||
const m = ev.data;
|
||||
if (!m || m.__arcrun !== 'action') return;
|
||||
const reply = (payload) =>
|
||||
f.contentWindow && f.contentWindow.postMessage({ __arcrun: 'result', id: m.id, payload }, '*');
|
||||
try {
|
||||
const raw = await go.RunAppAction(accIdx, d.id, String(m.action || ''), JSON.stringify(m.payload || {}));
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(raw); } catch (e) { parsed = { ok: true, result: raw }; }
|
||||
reply({ ok: true, status: 200, d: parsed });
|
||||
} catch (ex) {
|
||||
reply({ ok: false, status: 0, d: { error: String(ex) } });
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', appFrameBridge);
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
if (!state) return;
|
||||
let html;
|
||||
if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
|
||||
if (page === 'apps') html = pageApps(state);
|
||||
else if (page.startsWith('app:')) {
|
||||
const p = page.split(':');
|
||||
html = pageApp(Number(p[1]), p.slice(2).join(':'));
|
||||
}
|
||||
else if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
|
||||
else if (page === 'ai') html = pageAI(state);
|
||||
else if (page === 'update') html = pageUpdate(state);
|
||||
else html = pageHome(state);
|
||||
@@ -447,12 +1011,111 @@ function wire() {
|
||||
document.querySelectorAll('[data-rm]').forEach((b) => {
|
||||
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
|
||||
});
|
||||
// #44:資料夾結構。換頁/重畫之後把本來就展開著的那幾棵補回去——
|
||||
// 不補的話使用者每次切回這一頁都得重按一次(狀態在 treeState,畫面卻是空的)。
|
||||
document.querySelectorAll('[data-tree]').forEach((b) => {
|
||||
b.onclick = () => toggleFolderTree(b.dataset.tree);
|
||||
if (treeState.open[b.dataset.tree]) renderFolderTree(b.dataset.tree);
|
||||
});
|
||||
|
||||
// ── App 啟動器(arcrun-rag#137)──
|
||||
on('apConnect', showConnect);
|
||||
on('apRefresh', () => loadApps(appsAccIdx, true));
|
||||
on('apRetry', () => loadApps(appsAccIdx, true));
|
||||
on('apAdd', showHowToInstallApp);
|
||||
on('apReload', () => { const p = page.split(':'); loadAppDetail(Number(p[1]), p.slice(2).join(':')); });
|
||||
on('apLogin', appLogin);
|
||||
document.querySelectorAll('[data-appacc]').forEach((b) => {
|
||||
b.onclick = () => { appsAccIdx = Number(b.dataset.appacc); renderPage(); loadApps(appsAccIdx); };
|
||||
});
|
||||
document.querySelectorAll('[data-appopen]').forEach((b) => {
|
||||
b.onclick = () => goToApp(appsAccIdx, b.dataset.appopen);
|
||||
});
|
||||
document.querySelectorAll('[data-appback]').forEach((b) => {
|
||||
b.onclick = () => { page = 'apps'; appDetail = null; appDetailKey = ''; renderNav(); renderPage(); };
|
||||
});
|
||||
document.querySelectorAll('[data-apprun]').forEach((b) => { b.onclick = () => runAppAction(b); });
|
||||
|
||||
// App 自帶畫面:DOM 換好之後才掛 iframe(srcdoc 要等元素真的在文件裡)
|
||||
if (page.startsWith('app:') && appDetail && appDetail.hasUi && appDetail.uiHtml) {
|
||||
mountAppUI(Number(page.split(':')[1]), appDetail);
|
||||
}
|
||||
}
|
||||
|
||||
// goToApp 換到某個 App 的頁。換頁前先把上一個 App 的 postMessage 監聽器拆掉——
|
||||
// 不拆的話每開一次 App 就多留一個死監聽器(而且它還綁著舊的 accIdx/appId)。
|
||||
function goToApp(accIdx, id) {
|
||||
if (appFrameBridge) { window.removeEventListener('message', appFrameBridge); appFrameBridge = null; }
|
||||
page = 'app:' + accIdx + ':' + id;
|
||||
renderNav();
|
||||
loadAppDetail(accIdx, id);
|
||||
}
|
||||
|
||||
// 「加裝 App」:桌面端**不假裝自己能安裝**——安裝是實例上的動作
|
||||
// (跟 AI 說一句話,或 acr 推一份宣告)。這裡只把「東西從哪來」講清楚,
|
||||
// 順便給一個開知識庫網頁的出口。
|
||||
function showHowToInstallApp() {
|
||||
const acc = (state.accounts || [])[appsAccIdx];
|
||||
const portal = acc ? 'https://' + acc.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/' : '';
|
||||
openSheet(`
|
||||
<h2>怎麼加裝 App?</h2>
|
||||
<p>App 是裝在<b>知識庫</b>上的,不是裝在這台電腦上——所以你在任何一台電腦、
|
||||
或在知識庫網頁上,看到的都是同一批 App。</p>
|
||||
<p>兩種裝法:跟你的 AI 說「幫我裝一個 ⋯⋯」,或用 <b>acr</b> 把一份 App 宣告推上去。
|
||||
裝好之後回到這裡按「重新整理」就會出現。</p>
|
||||
<div class="acts">
|
||||
<button id="c1">知道了</button>
|
||||
${portal ? `<button class="primary" id="c2">開啟知識庫網頁</button>` : ''}
|
||||
</div>`,
|
||||
() => {
|
||||
$('c1').onclick = closeSheet;
|
||||
if ($('c2')) $('c2').onclick = () => { go.OpenURL(portal); closeSheet(); };
|
||||
});
|
||||
}
|
||||
|
||||
async function appLogin() {
|
||||
const accIdx = Number(page.split(':')[1]);
|
||||
const id = page.split(':').slice(2).join(':');
|
||||
const err = $('apErr');
|
||||
const btn = $('apLogin');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await go.PortalLogin(accIdx, $('apPw').value);
|
||||
if (btn) btn.disabled = false;
|
||||
loadAppDetail(accIdx, id);
|
||||
} catch (ex) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (err) { err.textContent = String(ex); err.style.display = 'block'; }
|
||||
}
|
||||
}
|
||||
|
||||
// runAppAction:沒有自帶畫面的 App,那顆「現在執行」。
|
||||
// 🔴 白名單是**實例**裁決的(K6)——這裡不認得任何動作名稱,只負責把按鈕送出去、
|
||||
// 把實例回的話原樣顯示。失敗就說失敗,不改寫成「可能成功」。
|
||||
async function runAppAction(btn) {
|
||||
const accIdx = Number(page.split(':')[1]);
|
||||
const id = page.split(':').slice(2).join(':');
|
||||
const item = btn.closest('.wfitem');
|
||||
const out = item && item.querySelector('.out');
|
||||
btn.disabled = true;
|
||||
if (out) { out.className = 'out'; out.textContent = '執行中…'; }
|
||||
try {
|
||||
const raw = await go.RunAppAction(accIdx, id, btn.dataset.apprun, '{}');
|
||||
if (out) out.textContent = '完成:' + String(raw).slice(0, 600);
|
||||
} catch (ex) {
|
||||
if (out) { out.className = 'out bad'; out.textContent = '失敗:' + String(ex); }
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
const first = !state;
|
||||
const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
|
||||
!== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
|
||||
// arcrun-rag#137:還沒連上任何知識庫時,第一眼要落在連線精靈(首頁),
|
||||
// 不是一個註定空的 App 啟動器。連上之後(accounts 從 0 變成 1)也不要硬把
|
||||
// 使用者拉走——他當下正在看剛連好的東西。
|
||||
if (first && page === 'apps' && !(s.accounts || []).length) page = 'home';
|
||||
state = s;
|
||||
$('ver').textContent = s.version || '';
|
||||
$('statusBig').textContent = s.statusBig;
|
||||
@@ -460,6 +1123,9 @@ function render(s) {
|
||||
$('statusSub').textContent = s.statusSub;
|
||||
if (first || navChanged) { renderNav(); renderPage(); }
|
||||
else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
|
||||
// 🔴 這是**唯一**一次自動去問實例:第一次拿到 state(=知道有哪些知識庫)之後。
|
||||
// 之後只有使用者按重新整理/切知識庫才會再問一次——**不掛在每秒的 tick 上**。
|
||||
if (first && page === 'apps' && (s.accounts || []).length) loadApps(appsAccIdx);
|
||||
}
|
||||
|
||||
async function tick() { try { render(await go.GetState()); } catch (e) {} }
|
||||
@@ -481,28 +1147,70 @@ async function addFolder(accIdx) {
|
||||
// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」,
|
||||
// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。
|
||||
// 現在兩個選擇都擺出來、後果各寫一行,由他挑。
|
||||
// 🔴 arcrun-rag#138(leo 2026-08-24):「碎型會在每個資料夾安裝隱藏資料夾,人工刪除不容易,
|
||||
// 所以當它斷連,應該要可以幫它把 Arcrun RAG 建立的資料夾刪掉」
|
||||
// ⇒ 多一個**獨立的勾選框**,不是第三顆單選:雲端怎麼處理、硬碟怎麼處理是兩件事,
|
||||
// 合成一個選項就又是替他決定(#46 修掉的正是那個病)。
|
||||
// 🔴 預設不勾——刪檔不可逆,預設值往「什麼都不動」倒。
|
||||
// 🔴 勾了才去問清單,並且把**每一筆路徑攤出來**:#138 的驗收條件白紙黑字寫著
|
||||
// 「使用者要能在動手前看到將要刪掉哪些東西」,按下去就無聲刪光不算做完。
|
||||
function confirmRemove(accIdx, path) {
|
||||
openSheet(`
|
||||
<h2>移除這個資料夾?</h2>
|
||||
<p>「${esc(path)}」要怎麼處理?<b>你電腦裡的原始檔案不會被動到</b>,兩個選擇的差別只在雲端。</p>
|
||||
<p>「${esc(path)}」要怎麼處理?<b>你自己的檔案不會被動到</b>——下面兩個選擇差在雲端,最後那個勾選框差在你的硬碟。</p>
|
||||
<label class="radio"><input type="radio" name="rmMode" value="takedown" checked/>
|
||||
<span><b>連同雲端的知識一起收回</b><br/>
|
||||
<span class="d">這個資料夾整理出來的知識會從知識庫刪除,之後搜尋找不到、AI 也不會再拿它回答。<b>刪掉就要不回來</b>。</span></span></label>
|
||||
<label class="radio"><input type="radio" name="rmMode" value="unwatch"/>
|
||||
<span><b>只停止同步,雲端的知識保留</b><br/>
|
||||
<span class="d">以後這個資料夾有變動不會再上傳,但之前整理好的知識留在知識庫裡,搜尋和 AI 照樣找得到。</span></span></label>
|
||||
<label class="radio"><input type="checkbox" id="rmClean"/>
|
||||
<span><b>順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉</b><br/>
|
||||
<span class="d">我們會在每一層資料夾放一個隱藏的整理稿目錄(<code>.wiki</code>/<code>.arcrun-rag</code>),
|
||||
散在各層、你自己很難刪乾淨。勾起來會先列出<b>確切要刪哪些</b>給你看過再動手;認不出是我們建的一律留著。</span></span></label>
|
||||
<div id="rmPlan" class="d" style="display:none;margin:8px 0 4px"></div>
|
||||
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">確定</button></div>`,
|
||||
() => {
|
||||
$('c1').onclick = closeSheet;
|
||||
const box = $('rmClean'), out = $('rmPlan');
|
||||
box.onchange = async () => {
|
||||
if (!box.checked) { out.style.display = 'none'; out.innerHTML = ''; return; }
|
||||
out.style.display = ''; out.textContent = '正在看這個資料夾裡有哪些是我們建的…';
|
||||
try {
|
||||
out.innerHTML = renderCleanupPlan(await go.PlanFolderCleanup(accIdx, path));
|
||||
} catch (e) {
|
||||
out.textContent = '看不到清單(' + e + ')——沒把握就先別勾這一項。';
|
||||
}
|
||||
};
|
||||
$('c2').onclick = async () => {
|
||||
const mode = document.querySelector('input[name="rmMode"]:checked');
|
||||
const takedown = !mode || mode.value === 'takedown';
|
||||
await go.RemoveFolder(accIdx, path, takedown); closeSheet();
|
||||
await go.RemoveFolder(accIdx, path, takedown, box.checked); closeSheet();
|
||||
state = await go.GetState(); renderNav(); renderPage();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// renderCleanupPlan 把「將要刪掉什麼/刻意留下什麼」攤成使用者看得懂的清單。
|
||||
// 🔴 留下的那一半一樣要顯示:沉默地留下殘渣,跟沉默地刪掉一樣糟——他要的是
|
||||
// 「這個資料夾乾淨了」,那就得讓他看得到還有什麼沒清、為什麼沒清。
|
||||
function renderCleanupPlan(plan) {
|
||||
const rm = (plan && plan.remove) || [], keep = (plan && plan.keep) || [];
|
||||
if (!rm.length && !keep.length) return '這個資料夾裡沒有找到任何 Arcrun RAG 建立的檔案,不需要清理。';
|
||||
let h = '';
|
||||
if (rm.length) {
|
||||
h += `<b>會刪掉這 ${rm.length} 項(共 ${plan.files} 個檔):</b><ul style="margin:4px 0 0 16px">`;
|
||||
for (const it of rm) h += `<li>${esc(it.rel)}${it.is_dir ? '/' : ''}(${it.files} 個檔)</li>`;
|
||||
h += '</ul>';
|
||||
}
|
||||
if (keep.length) {
|
||||
h += `<b style="display:block;margin-top:8px">這 ${keep.length} 項我不會動:</b><ul style="margin:4px 0 0 16px">`;
|
||||
for (const k of keep) h += `<li>${esc(k.rel)} — ${esc(k.reason)}</li>`;
|
||||
h += '</ul>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function showConnect() {
|
||||
openSheet(`
|
||||
<h2>連上你的知識庫</h2>
|
||||
|
||||
@@ -144,6 +144,41 @@ button.ghost:hover { color: var(--err); background: transparent; }
|
||||
.folder .tag.retiring { color: rgba(var(--ink-rgb), .5); }
|
||||
.folder-note { margin: -3px 0 9px; padding: 0 15px; font-size: 12.5px; }
|
||||
|
||||
/* ══ arcrun-rag#159:那一列只剩「三角形 + 路徑 + 兩個圖示」══════════════
|
||||
leo 2026-08-28:「補送中、資料夾結構、移除,**變成 3 個 emoticon 就好,
|
||||
畫面上都是字壓力很大**」「**展開三角形按鈕放資料夾上**」
|
||||
⇒ 三角形移到列首(跟樹裡面的展開鈕同一個造型,使用者只要學一次)。 */
|
||||
.folder > .tw {
|
||||
flex: none; width: 22px; height: 22px; padding: 0; border: none; background: none;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.folder > .tw:hover { background: none; }
|
||||
/* 三角形用 CSS 畫,不用 ▸▾ 字元——理由同樹裡那顆(見 .ftrow .tw i):
|
||||
那兩個字在 Windows 的中文字型裡是全形,大小與位置每台機器都不一樣。 */
|
||||
.folder > .tw i {
|
||||
display: block; width: 0; height: 0;
|
||||
border-left: 6px solid rgba(var(--ink-rgb), .5);
|
||||
border-top: 5px solid transparent; border-bottom: 5px solid transparent;
|
||||
transition: transform .12s;
|
||||
}
|
||||
.folder.open > .tw i { transform: rotate(90deg); }
|
||||
.folder > .tw:hover i { border-left-color: var(--amber); }
|
||||
/* 狀態圖示。**一個字都沒有**——說明住在 title/aria-label 裡,滑過去才出現。 */
|
||||
.folder .fstat { flex: none; font-size: 14px; line-height: 1; cursor: default; }
|
||||
/* 🔴 視覺層級要對得上意思:**只有需要你出手的那一格才准大聲**。
|
||||
「同步中」是常態、不需要任何人做什麼 ⇒ 壓到跟灰階一樣安靜;
|
||||
⚠️ 維持原色,因為它是唯一一個「你可能要看一下」的狀態。
|
||||
(不壓的話 🔄 的藍色會比 ⚠️ 還跳,畫面就在對使用者喊錯的東西。) */
|
||||
.folder .fstat.working { filter: grayscale(.85); opacity: .5; }
|
||||
/* 還沒回報過的那一格刻意做得很淡:它不是「沒問題」,是「還不知道」。 */
|
||||
.folder .fstat.unknown { color: rgba(var(--ink-rgb), .3); font-size: 12px; }
|
||||
/* 圖示按鈕(移除):跟三角形同一個量級,hover 才變紅——不預設嚷嚷。 */
|
||||
.folder .ico {
|
||||
flex: none; padding: 4px 6px; border: none; background: none;
|
||||
font-size: 14px; line-height: 1; filter: grayscale(1); opacity: .7;
|
||||
}
|
||||
.folder .ico:hover { background: none; filter: none; opacity: 1; }
|
||||
|
||||
.big-num { font-size: 26px; font-weight: 600; letter-spacing: .01em; }
|
||||
.kv { display: flex; gap: 26px; margin-top: 4px; }
|
||||
.kv .k { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }
|
||||
@@ -299,8 +334,211 @@ input[type=text], input[type=password] {
|
||||
.kbrow .nm { font-size: 14px; font-weight: 600; flex: none; }
|
||||
.kbrow .right { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.kbver { margin-bottom: 14px; font-size: 13.5px; display: flex; align-items: center; gap: 10px; }
|
||||
/* arcrun-rag#159:版本那一格只有**數字與圖示**,說明收進 title。
|
||||
等寬字讓 `1.4.46 → 1.4.58` 這種對照掃視時對得齊。 */
|
||||
.kbv { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: rgba(var(--ink-rgb), .7); }
|
||||
/* 這一輪連不上 ⇒ 版本號調淡表示「這是上次查到的」。**不是把它抹掉**——
|
||||
版本查到過就是事實,抽風的是網路(見 collector/direct.go #159 那段)。 */
|
||||
.kbv.dim { opacity: .45; }
|
||||
/* 從來沒查到過:一個很淡的圈,跟資料夾那一列的 unknown 同一個語彙。 */
|
||||
.kbv.unknown { font-family: inherit; color: rgba(var(--ink-rgb), .3); }
|
||||
.warn { color: var(--err); font-size: 13px; }
|
||||
#side .nav .dot.warn {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex: none;
|
||||
background: var(--err); margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
App 啟動器(arcrun-rag#137)
|
||||
leo 2026-08-24:「所有的 App 需要有一個類似 Android/iOS 的**九宮格啟動界面**,
|
||||
每個 App 有一個 icon,這會運行在 portal 及 daemon。」
|
||||
|
||||
🔴 尺寸/圓角/間距逐項照 leo 已核准的設計稿(Launcher-Desktop.dc.html):
|
||||
磁磚 88x88、圓角 22、格線 gap 32/24、hover 上浮 2px、標籤 14px 置中。
|
||||
🔴 顏色**一個 hex 都不准出現**——全部走 arcrun-cis.css 的變數(本檔開頭那條規矩)。
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 頁首:知識庫名 + 幾個 App + 重新整理(同 .libhead 的結構,不另發明一種頁首) */
|
||||
.apphead {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding-bottom: 18px; margin-bottom: 26px;
|
||||
border-bottom: 2px solid rgba(var(--amber-rgb), .35); /* 設計稿的 accentSoft 分隔線 */
|
||||
}
|
||||
.apphead .g { flex: 1; min-width: 0; }
|
||||
.apphead .t { font-size: 22px; font-weight: 600; letter-spacing: .04em; }
|
||||
.apphead .s { margin-top: 5px; font-size: 13.5px; color: rgba(var(--ink-rgb), .55); }
|
||||
|
||||
/* 多個知識庫時才出現的切換器(只有一個庫時畫面上不該有這排噪音) */
|
||||
.appswitch { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
/* 圓角用 10px(同 button),刻意**不用**膠囊——check-cis.sh 明文擋膠囊選中態
|
||||
(leo 08-04 否決過),而且這排切換器與按鈕本來就該是同一種形狀語彙。 */
|
||||
.appswitch .chip {
|
||||
padding: 6px 13px; font-size: 13px; border-radius: 10px;
|
||||
border: 1px solid rgba(var(--ink-rgb), .18);
|
||||
color: rgba(var(--ink-rgb), .6); cursor: pointer; user-select: none;
|
||||
}
|
||||
.appswitch .chip.on {
|
||||
border-color: var(--amber); color: var(--amber);
|
||||
background: rgba(var(--amber-rgb), .08); font-weight: 600;
|
||||
}
|
||||
|
||||
/* 九宮格本體。auto-fill 而不是固定 4 欄:桌面視窗是可以被拉窄的(min 820px),
|
||||
固定欄數會在窄視窗把磁磚擠變形。 */
|
||||
.appgrid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
gap: 32px 24px;
|
||||
}
|
||||
.appcell { display: flex; flex-direction: column; align-items: center; gap: 11px; }
|
||||
.apptile {
|
||||
width: 88px; height: 88px; border-radius: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 38px; line-height: 1;
|
||||
background: var(--well); border: 1px solid rgba(var(--ink-rgb), .1);
|
||||
cursor: pointer; user-select: none;
|
||||
transition: border-color .12s, background .12s, transform .12s;
|
||||
}
|
||||
.apptile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--amber);
|
||||
background: rgba(var(--amber-rgb), .08);
|
||||
}
|
||||
/* 「加裝 App」——設計稿是虛線框+加號,語意是「這一格還沒有東西」 */
|
||||
.apptile.add {
|
||||
background: none; border: 1.5px dashed rgba(var(--ink-rgb), .22);
|
||||
font-size: 28px; color: rgba(var(--ink-rgb), .45);
|
||||
}
|
||||
.appcell .nm {
|
||||
font-size: 14px; text-align: center; line-height: 1.4;
|
||||
max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.appcell .nm.dim { color: rgba(var(--ink-rgb), .55); }
|
||||
|
||||
/* App 頁 */
|
||||
.appview { display: flex; flex-direction: column; }
|
||||
.appview .head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding-bottom: 14px; margin-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(var(--ink-rgb), .1);
|
||||
}
|
||||
.appview .head .ico { font-size: 24px; line-height: 1; }
|
||||
.appview .head .nm { font-size: 17px; font-weight: 600; }
|
||||
.appview .head .vr {
|
||||
font-size: 12px; color: rgba(var(--ink-rgb), .4);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.appview .head .sp { flex: 1; }
|
||||
|
||||
/* App 自帶畫面:**關在 sandbox iframe 裡**(見 main.js mountAppUI 的理由)。
|
||||
高度吃滿剩下的版面,讓 App 自己決定要怎麼排。 */
|
||||
/* 高度用 calc 而不是 flex:1/height:100%:#page 是 flex item + 自帶 padding,
|
||||
百分比高度在那裡會多算出一段 padding 而長出一條假捲軸。
|
||||
250px=頁首(約 90) + main padding(48) + App 頁首(約 60) + 呼吸。 */
|
||||
.appframe {
|
||||
width: 100%; height: calc(100vh - 250px); min-height: 420px;
|
||||
border: 1px solid rgba(var(--ink-rgb), .1); border-radius: 12px;
|
||||
background: var(--paper-a);
|
||||
}
|
||||
|
||||
/* 沒有自帶畫面的 App=列出它的工作流,一條一顆「現在執行」
|
||||
(與 Portal 的系統預設畫面同一套呈現,不另立第二種) */
|
||||
.wfitem {
|
||||
background: var(--well); border-radius: 12px;
|
||||
padding: 15px 18px; margin-bottom: 10px;
|
||||
}
|
||||
.wfitem .top { display: flex; align-items: baseline; gap: 12px; }
|
||||
.wfitem .nm { font-size: 15px; font-weight: 600; flex: 1; min-width: 0; }
|
||||
.wfitem .d { margin-top: 7px; font-size: 13.5px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
|
||||
.wfitem .out {
|
||||
margin-top: 8px; font-size: 12.5px; line-height: 1.6;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
color: rgba(var(--ink-rgb), .55);
|
||||
word-break: break-all; white-space: pre-wrap;
|
||||
}
|
||||
.wfitem .out.bad { color: var(--err); }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半;形狀規格 `inkstone/Arcrun#144`)
|
||||
|
||||
leo 2026-08-19(驗 1.4.49 的 markmap 之後打回):
|
||||
「我的需求是**向右向下**,類似 terminal 的 tree,**一列一列向下往後退縮**,
|
||||
緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**⋯⋯
|
||||
可以用滑鼠輕易操作,**不會佔用大面積**。」
|
||||
|
||||
⇒ 這一段的每個數字都在服務那句話:
|
||||
· 一列 = 一個 flex row,靠 padding-left 退縮(縮排 16px/層,同檔案總管量級)
|
||||
· 行高壓到 20px 上下、字級 13px ⇒ 1080p 一個畫面塞得下數十列
|
||||
· 三角形+資料夾 icon 在最左,數字與原因往右排,掃視時眼睛只走一條直線
|
||||
🔴 顏色一個 hex 都不准出現(本檔開頭那條規矩)——全部走 arcrun-cis.css 的變數。
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ftbox {
|
||||
margin: -3px 0 9px; padding: 8px 10px 6px;
|
||||
background: var(--well); border-radius: 10px;
|
||||
}
|
||||
/* 🔴 arcrun-rag#159:`.fthead`(全部展開/全部收合+收檔策略那句話)**整條移除**。
|
||||
leo 2026-08-28 在截圖上圈掉它:「上下廢話刪除,只留乾淨 Tree」
|
||||
「產生 GUI 就是要讓人減少讀字降低負擔,你在 GUI 寫這麼多字剛好違背它的原理」。
|
||||
收檔策略那句話沒有被刪掉——它改掛在根那一列的數字上,點一下就看得到。 */
|
||||
/* 樹本體:檔案總管那種「緊湊但點得到」的密度。
|
||||
overflow-x 讓很深的樹自己捲,不去撐破右側版面。 */
|
||||
.ftbody { overflow-x: auto; padding: 1px 0; }
|
||||
.ftrow {
|
||||
display: flex; align-items: baseline; gap: 7px;
|
||||
padding: 2px 4px 2px 0; font-size: 13px; line-height: 1.5;
|
||||
white-space: nowrap; border-radius: 5px;
|
||||
}
|
||||
/* 有子資料夾的那幾列才可點——整列都是點擊區(三角形只有 12px,滑鼠不好瞄)。 */
|
||||
.ftrow.has { cursor: pointer; user-select: none; }
|
||||
.ftrow.has:hover { background: rgba(var(--amber-rgb), .08); }
|
||||
.ftrow.has:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
|
||||
/* 三角形:**用 CSS 畫,不用字元**。
|
||||
leo 2026-08-19 打回 markmap 的其中一條理由就是「**展開字很小**⋯⋯
|
||||
用戶要擁有觸控板,否則很難閱讀」——▸▾ 這兩個字在不同平台的中文字型裡
|
||||
大小與位置都不一樣(Windows 常被當全形處理),畫出來的則到處一致。 */
|
||||
.ftrow .tw {
|
||||
width: 14px; flex: none; align-self: center;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ftrow .tw i {
|
||||
display: block; width: 0; height: 0;
|
||||
border-left: 5px solid rgba(var(--ink-rgb), .5);
|
||||
border-top: 4px solid transparent; border-bottom: 4px solid transparent;
|
||||
transition: transform .12s;
|
||||
}
|
||||
.ftrow.open .tw i { transform: rotate(90deg); }
|
||||
.ftrow.has:hover .tw i { border-left-color: var(--amber); }
|
||||
.ftrow .ic { flex: none; font-size: 12.5px; }
|
||||
.ftrow .nm { font-weight: 500; }
|
||||
.ftrow .num {
|
||||
font-family: ui-monospace, Menlo, monospace; font-size: 12px;
|
||||
color: rgba(var(--ink-rgb), .55);
|
||||
}
|
||||
/* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */
|
||||
.ftrow .num.full { color: var(--ink); }
|
||||
/* #159:這一列有「為什麼」可以看時,數字自己就是那顆按鈕(畫面上不多一個字、
|
||||
也不多一顆按鈕)。虛線底線是唯一的提示——**它不是裝飾,是可點的告示**。 */
|
||||
.ftrow .num.hasWhy {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dotted rgba(var(--ink-rgb), .35);
|
||||
}
|
||||
.ftrow .num.hasWhy:hover { color: var(--ink); border-bottom-color: var(--amber); }
|
||||
.ftrow .num.hasWhy:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
|
||||
/* 點開之後那一行理由。預設不存在——這就是「收起來不等於刪掉」的落點。 */
|
||||
.ftwhy {
|
||||
font-size: 12px; line-height: 1.6; padding: 1px 4px 4px;
|
||||
color: rgba(var(--ink-rgb), .55); white-space: normal; max-width: 46em;
|
||||
}
|
||||
/* 🔴 選擇器**不要**綁在 .ftrow 底下:頁首那句「為什麼只收這些」也用同一個 class,
|
||||
綁死就只有樹裡面吃得到樣式、頁首那句會掉回 16px 的內文級——實撞過一次,
|
||||
畫面上那句話比整棵樹都大聲,喧賓奪主。 */
|
||||
/* 「為什麼沒上去」是次要資訊:**它可以被截斷,資料夾名字不行**。
|
||||
(整棵未收的理由是一整句話,不截的話一列會長到把樹推出畫面外。)
|
||||
截掉的部分留在 title 裡,滑鼠停著就看得到全文。 */
|
||||
.ftbox .why {
|
||||
font-size: 12px; color: rgba(var(--ink-rgb), .45);
|
||||
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ftmsg {
|
||||
padding: 6px 2px 2px; font-size: 12px; line-height: 1.65;
|
||||
color: rgba(var(--ink-rgb), .45);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// remove_folder_cleanup_test.go — arcrun-rag#138:移除資料夾時可以連同硬碟上的
|
||||
// 隱藏資料夾一起收掉,而且**只收我們自己建的**。
|
||||
//
|
||||
// 本檔釘 App 這一半的四件事(認人的判準本身由 collector/cleanup_test.go 釘):
|
||||
// 1. 沒勾清理 ⇒ 硬碟上一個檔都不准少(預設不刪,跟 #46 同一條紅線)
|
||||
// 2. 勾了清理 ⇒ 我們的隱藏資料夾(含巢狀層)沒了,使用者的檔案一個不少
|
||||
// 3. 另一個還在看守(或還在收回中)的資料夾底下 ⇒ 不碰
|
||||
// 4. PlanFolderCleanup 只讀不寫,且拿得到「將要刪掉哪些」的清單
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
|
||||
func mkfile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeFakeWatched 造一個「daemon 跑過」的資料夾:使用者的原稿+我們的隱藏產物(兩層)。
|
||||
func makeFakeWatched(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
mkfile(t, filepath.Join(root, "我的原稿.md"), "使用者的知識\n")
|
||||
mkfile(t, filepath.Join(root, "docs", "設計.md"), "使用者的設計\n")
|
||||
// 工作區與各層卡片目錄的內容逐字取自 collector 真正寫下去的那兩份宣告
|
||||
mkfile(t, filepath.Join(root, ".arcrun-rag", ".gitignore"), collector.WorkspaceIgnoreBodyForTest())
|
||||
for _, node := range []string{"", "docs"} {
|
||||
dir := filepath.Join(root, node, ".wiki")
|
||||
mkfile(t, filepath.Join(dir, ".gitignore"), collector.WikiIgnoreBodyForTest())
|
||||
mkfile(t, filepath.Join(dir, "00-INDEX.md"), "# 00-INDEX\n\n## 文件\n")
|
||||
mkfile(t, filepath.Join(dir, "arcrun-一張卡.md"), "# 卡\n")
|
||||
}
|
||||
}
|
||||
|
||||
func listFiles(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ① 沒勾清理=行為與從前一字不差:硬碟上一個檔都不准少。
|
||||
func TestRemoveFolderWithoutCleanupTouchesNothing(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
root := filepath.Join(t.TempDir(), "kb")
|
||||
makeFakeWatched(t, root)
|
||||
newTestCfgWithFolder(t, root)
|
||||
|
||||
before := listFiles(t, root)
|
||||
if err := (&App{}).RemoveFolder(0, root, false, false); err != nil {
|
||||
t.Fatalf("移除失敗:%v", err)
|
||||
}
|
||||
after := listFiles(t, root)
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("沒勾清理就不准動硬碟:before=%v after=%v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 勾了清理:我們的東西沒了,使用者的東西一個不少。
|
||||
func TestRemoveFolderWithCleanupRemovesOnlyOurs(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
root := filepath.Join(t.TempDir(), "kb")
|
||||
makeFakeWatched(t, root)
|
||||
newTestCfgWithFolder(t, root)
|
||||
|
||||
if err := (&App{}).RemoveFolder(0, root, false, true); err != nil {
|
||||
t.Fatalf("移除失敗:%v", err)
|
||||
}
|
||||
got := listFiles(t, root)
|
||||
want := []string{"docs/設計.md", "我的原稿.md"}
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("清完之後應該只剩使用者的檔\n got=%v\nwant=%v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("清完之後應該只剩使用者的檔\n got=%v\nwant=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 巢狀在裡面、但**還在收回中**的資料夾 ⇒ 不碰它底下的東西。
|
||||
func TestRemoveFolderCleanupSkipsRetiringNestedRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
root := filepath.Join(t.TempDir(), "kb")
|
||||
inner := filepath.Join(root, "legacy")
|
||||
makeFakeWatched(t, root)
|
||||
makeFakeWatched(t, inner)
|
||||
|
||||
cfg := &directConfig{
|
||||
Accounts: []accountCfg{{
|
||||
CypherURL: "https://example.workers.dev", Namespace: "abc", APIKey: "abc",
|
||||
WatchFolders: []string{root},
|
||||
RetiringFolders: []string{inner}, // 還在把雲端的資料收回來,別動它的檔
|
||||
}},
|
||||
Extractor: "workers-ai",
|
||||
}
|
||||
if err := saveCfg(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&App{}).RemoveFolder(0, root, false, true); err != nil {
|
||||
t.Fatalf("移除失敗:%v", err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(inner, ".arcrun-rag", ".gitignore"),
|
||||
filepath.Join(inner, ".wiki", "arcrun-一張卡.md"),
|
||||
filepath.Join(inner, "docs", ".wiki", "arcrun-一張卡.md"),
|
||||
} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("還在收回中的資料夾底下的東西被刪了:%s(%v)", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".wiki")); !os.IsNotExist(err) {
|
||||
t.Fatalf("外層自己的 .wiki 該被清掉")
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 先看清單:拿得到內容,而且看完之後硬碟一個檔都沒變。
|
||||
func TestPlanFolderCleanupIsReadOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
root := filepath.Join(t.TempDir(), "kb")
|
||||
makeFakeWatched(t, root)
|
||||
newTestCfgWithFolder(t, root)
|
||||
|
||||
before := listFiles(t, root)
|
||||
plan, err := (&App{}).PlanFolderCleanup(0, root)
|
||||
if err != nil {
|
||||
t.Fatalf("拿不到清單:%v", err)
|
||||
}
|
||||
if len(plan.Remove) == 0 {
|
||||
t.Fatal("這份擺法應該列得出東西")
|
||||
}
|
||||
for _, it := range plan.Remove {
|
||||
if it.Evidence == "" {
|
||||
t.Fatalf("清單上每一筆都要說得出依據:%+v", it)
|
||||
}
|
||||
}
|
||||
if after := listFiles(t, root); len(after) != len(before) {
|
||||
t.Fatalf("看清單這個動作動了硬碟:before=%v after=%v", before, after)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func TestRemoveFolderWithTakedownQueuesRetirement(t *testing.T) {
|
||||
t.Setenv("USERPROFILE", home)
|
||||
newTestCfgWithFolder(t, "/kb/logseq-plugin")
|
||||
|
||||
if err := (&App{}).RemoveFolder(0, "/kb/logseq-plugin", true); err != nil {
|
||||
if err := (&App{}).RemoveFolder(0, "/kb/logseq-plugin", true, false); err != nil {
|
||||
t.Fatalf("移除失敗:%v", err)
|
||||
}
|
||||
cfg, err := loadCfg()
|
||||
@@ -90,7 +90,7 @@ func TestRemoveFolderWithoutTakedownKeepsCloudData(t *testing.T) {
|
||||
t.Setenv("USERPROFILE", home)
|
||||
newTestCfgWithFolder(t, "/kb/keep-cloud")
|
||||
|
||||
if err := (&App{}).RemoveFolder(0, "/kb/keep-cloud", false); err != nil {
|
||||
if err := (&App{}).RemoveFolder(0, "/kb/keep-cloud", false, false); err != nil {
|
||||
t.Fatalf("移除失敗:%v", err)
|
||||
}
|
||||
cfg, _ := loadCfg()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
// tray_other.go — 非出貨平台(Linux 等)的系統匣 no-op(arcrun-rag#137)。
|
||||
//
|
||||
// 為什麼加這一個檔:`setupTray` 只在 `tray_darwin.go`/`tray_windows.go` 有實作,
|
||||
// 所以整包在 Linux **連編都編不起來**(`undefined: setupTray`)——
|
||||
// 連帶 `go build ./...`/`go test ./...` 這兩件最基本的機械驗證在雲端/CI 上做不了,
|
||||
// 而這台桌面 App 的出貨機是 macOS 與 Windows,本來就沒人會在 Linux 跑它。
|
||||
//
|
||||
// ⇒ 補一個 no-op,讓「編得過/測得過」這條線在任何機器上都成立。
|
||||
// **對出貨零影響**:darwin 與 windows 兩個 tag 都排除了這個檔,
|
||||
// 兩邊拿到的仍然是各自那份真正的托盤實作。
|
||||
//
|
||||
// 🔴 這不是「Linux 版桌面 App」:Linux 上沒有托盤、也沒有人測過 WebView,
|
||||
//
|
||||
// 它存在的唯一理由是讓編譯器與測試跑得起來(同 dock_other.go 的先例)。
|
||||
func setupTray(_ *App) {}
|
||||
@@ -67,7 +67,7 @@ func TestWiring_docx轉檔發生在送LLM之前(t *testing.T) {
|
||||
writeDocx(t, filepath.Join(dir, rel), `<w:p><w:r><w:t>維修費用 350,000 元</w:t></w:r></w:p>`)
|
||||
|
||||
// 空 API key → 函式最前面就擋下,證明不了轉檔。給假 key 讓它走到轉檔那一步。
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("用假 key 不該成功")
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestWiring_抽不出文字要明確失敗不靜默(t *testing.T) {
|
||||
rel := "空的.docx"
|
||||
writeDocx(t, filepath.Join(dir, rel), `<w:p><w:r><w:t> </w:t></w:r></w:p>`)
|
||||
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("抽不出文字應該失敗")
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func TestWiring_未支援格式與無文字要能區分(t *testing.T) {
|
||||
rel := "簡報.ppt"
|
||||
os.WriteFile(filepath.Join(dir, rel), []byte("\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1fake"), 0o644)
|
||||
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("未支援格式應該失敗")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestWiring_md不受轉檔層影響(t *testing.T) {
|
||||
rel := "筆記.md"
|
||||
os.WriteFile(filepath.Join(dir, rel), []byte("# 標題\n\n內容"), 0o644)
|
||||
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel)
|
||||
_, err := ExtractWithGemma("fake-key-for-test", "", dir, rel, testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("假 key 不該成功")
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ type DirectConfig struct {
|
||||
// 所以改名不會讓庫裡憑空多出一台機器。
|
||||
MachineLabel string `json:"machine_label,omitempty"`
|
||||
|
||||
// guard=這一輪的「等待閘」(見 stallguard.go)。RunDirectOnce 每輪換一份新的;
|
||||
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去,所以同一輪的
|
||||
// 所有帳號、所有資料夾共用同一份紀錄——斷路器要跨得了資料夾才擋得住「一發卡住
|
||||
// 就整輪停擺」。不落 config 檔(它是這一次執行的狀態,不是使用者設定)。
|
||||
guard *roundGuard
|
||||
|
||||
// machine=解析好的機器身分快取(不落 config 檔:ID 的家是 machine.json,
|
||||
// 這裡只是這一輪的記憶體副本。makeAccountSubConfig 的 `sub := *c` 會一起複製,
|
||||
// 所以多帳號同一輪只解析一次、每個帳號送出的值必然一致)。
|
||||
@@ -467,7 +473,11 @@ func (c *DirectConfig) migrateManifestIfNeeded(absRoot, newPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// directHTTP 是 direct 模式共用的 HTTP client(萃取 workflow 可能同步跑 LLM,放寬 timeout)。
|
||||
// directHTTP 是 direct 模式共用的 HTTP client。
|
||||
//
|
||||
// 🔴 這把 Timeout 是**最後一道**保險,不是每一發的上限(`inkstone/arcrun-rag#153`):
|
||||
// 真正生效的上限由呼叫端的 callStep 帶進 context(見 stallguard.go),因為
|
||||
// 「送一份筆記」跟「請雲端同步跑完 AI 萃取」本來就不該共用同一個數字。
|
||||
var directHTTP = &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
// triggerURL 組出 named-webhook 觸發完整 URL。
|
||||
@@ -475,13 +485,53 @@ func (c *DirectConfig) triggerURL(workflow string) string {
|
||||
return fmt.Sprintf("%s/webhooks/named/%s/%s/trigger", c.CypherURL, c.Namespace, workflow)
|
||||
}
|
||||
|
||||
// folderTreeURL 組出「回報資料夾樹」的端點(InkStoneCo#44 線 A)。
|
||||
//
|
||||
// 🔴 為什麼不是 named-webhook(也就是不做成一條 workflow):這件事是**登記**,
|
||||
// 不是知識萃取——它寫的是 portal 的登記簿(庫目錄+資料夾節點),與
|
||||
// `/portal/daemon/extract`(extract_workersai.go:86)、`/portal/daemon/libraries`
|
||||
// 同一族、同一把 `X-Arcrun-API-Key`。做成 workflow 會為了「把結構化資料寫進登記簿」
|
||||
// 在 workflow 裡塞一串 code 節點,那正是「表面走 Arcrun、實際整段寫 JS」的腹語術。
|
||||
func (c *DirectConfig) folderTreeURL() string {
|
||||
return c.CypherURL + "/portal/daemon/folder-tree"
|
||||
}
|
||||
|
||||
// countsAsDocument 回答「這筆結果算不算**使用者的一份文件**」——
|
||||
// 也就是該不該進 status.json 的 `extracted_ok` / `extract_failed`(=托盤上那句
|
||||
// 「已整理 N 份 / ⚠ N 份失敗」)。
|
||||
//
|
||||
// 🔴 `folder_tree` 不算。它送的是**畫面用的資料夾結構**,不是知識、不是使用者的檔案:
|
||||
// 收端只寫一把 KV,一份文件都沒有經手。
|
||||
//
|
||||
// 📌 這是本分支自己踩到的(InkStoneCo#44):漏掉這道閘的症狀是**空資料夾**——
|
||||
// 一個檔都沒有,樹卻照樣要送(那正是 arcrun-rag#106 要的),一旦雲端連不上,
|
||||
// 使用者的托盤就會顯示「⚠ 1 份失敗」而他根本沒有半份檔案。
|
||||
// 「講一個假的 1」跟「安靜地少講」對使用者是同一件事(同 #104 那條紅線)。
|
||||
//
|
||||
// `inventory`(資料夾總覽卡)刻意**仍然算**:它是真的被寫進知識庫的一張卡,
|
||||
// 使用者在雲端查得到它——與這裡的樹不是同一種東西。
|
||||
func countsAsDocument(r DirectResult) bool {
|
||||
return r.Type != "folder_tree"
|
||||
}
|
||||
|
||||
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
|
||||
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
//
|
||||
// step 講的是「這一發在做什麼」(`inkstone/arcrun-rag#153`):它決定這一發自己的
|
||||
// 上限,也決定卡住時畫面與日誌上那句話怎麼寫。同一個網址在不同地方是不同的事
|
||||
//(收卡那條路,可能是使用者剛存的新檔,也可能是在補修舊筆記的出處)——
|
||||
// 所以 step 由呼叫端給,不從網址反推,反推出來的名字會說謊。
|
||||
func (c *DirectConfig) postJSON(step callStep, url string, body any) (int, string, error) {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
|
||||
gate := c.openGate(step)
|
||||
defer gate.release() // context 要活到下面讀完回應為止,所以是 defer 不是就地釋放
|
||||
// 這一輪已經判定這個帳號沒有回應 ⇒ 連打都不打,立刻回頭讓其他資料夾繼續。
|
||||
if note := gate.blocked(); note != "" {
|
||||
return 0, "", errors.New(note)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(gate.ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
@@ -489,13 +539,29 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
req.Header.Set("X-Arcrun-API-Key", c.APIKey)
|
||||
resp, err := directHTTP.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return 0, "", gate.record(err)
|
||||
}
|
||||
gate.ok() // #153:回來了就把「連續逾時」的計數歸零——否則「連續」兩個字是假的
|
||||
defer resp.Body.Close()
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
|
||||
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
|
||||
full, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
snippet := full
|
||||
if len(snippet) > 1024 {
|
||||
snippet = snippet[:1024]
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
|
||||
// named-webhook 觸發成功一律回 200,工作流內部失敗藏在 body 裡
|
||||
//(2026-08-26 實測 `InkStoneCo`:26 份蓋了「已送達」章,雲端只有 4 份)。
|
||||
// 判斷放在這裡而不是各呼叫端:**「忘了接」這個失敗模式不該存在**
|
||||
//(同 ingestplan.go 把兩張排除表收成一張的理由)。
|
||||
if msg := webhookFailure(string(full)); msg != "" {
|
||||
return resp.StatusCode, string(snippet), errors.New(msg)
|
||||
}
|
||||
return resp.StatusCode, string(snippet), nil
|
||||
}
|
||||
|
||||
@@ -567,6 +633,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
var lastPayload *TriggerPayload
|
||||
now := time.Now() // 2026-08-07 pacing task:整輪共用同一個時間點(排序/冷卻判斷一致、好測試)
|
||||
|
||||
// 🔴 `inkstone/arcrun-rag#153`:這一輪的「等待閘」。每輪換一份新的——斷路器只管
|
||||
// 這一輪,下一輪一律從零開始重新試(同步是 level-triggered 的,沒有什麼要記住)。
|
||||
// 指標會隨 makeAccountSubConfig 的 `sub := *c` 傳給每個帳號、每個資料夾,
|
||||
// 所以「這個帳號沒有回應」這件事跨得了資料夾——那正是本票要修的那條線。
|
||||
cfg.guard = newRoundGuard()
|
||||
|
||||
// 2026-08-07:提早載入上一輪 status(原本只在函式尾端載入做 CarryForwardActivity)。
|
||||
// 額度冷卻與「今天已萃幾份」是**跨輪持續的狀態**(quotaState 見 quota.go),
|
||||
// 要在處理帳號之前就知道上一輪冷卻到什麼時候、今天已經算到幾份。
|
||||
@@ -664,6 +736,16 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。
|
||||
folderPlans := map[string]FolderPlanStatus{}
|
||||
|
||||
// `inkstone/InkStoneCo#44`(桌面小幫手那半):每個看守資料夾這一輪的樹。
|
||||
// 與 folderPlans 同一套 key 與同一套覆蓋語意。knownRoots 記「這一輪打算處理哪些根」
|
||||
// ——合併時要靠它把已經不看守的根從快照裡刪掉(見 MergeFolderTreeStore ③)。
|
||||
folderTrees := map[string]FolderTree{}
|
||||
var knownRoots []string
|
||||
|
||||
// `inkstone/arcrun-rag#159`:每個看守資料夾這一輪的同步現況(key=資料夾路徑)。
|
||||
// 值是 (*Manifest).Progress() 的原件——與 totalProgress 同一個來源,只是沒有累加。
|
||||
folderProgress := map[string]SyncProgress{}
|
||||
|
||||
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
|
||||
var totalProgress SyncProgress
|
||||
var stuckReasons []string
|
||||
@@ -672,6 +754,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 每輪重建、照現況重報(level-triggered),App 看到 done 才把設定裡那一筆清掉。
|
||||
var retiring map[string]RetiringStatus
|
||||
|
||||
// `inkstone/arcrun-rag#140`:這一輪各個資料夾的「雲端補送」現況(key=資料夾路徑)。
|
||||
// 同 retiring 的 level-triggered 語意:每輪照 manifest 現況重報,補完自然消失。
|
||||
var resync map[string]ResyncStatus
|
||||
|
||||
// t215:全域「雲端最新版」只抓一次(自帶節流,見 cloud_latest.go)——
|
||||
// 這是所有帳號共用的同一把尺,不是逐帳號各打一次。
|
||||
latestRelease, latestOK := FetchLatestCloudRelease()
|
||||
@@ -686,8 +772,28 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
|
||||
// t103:per-account 雲端版本偵測
|
||||
cloudVer, cloudOK := fetchCloudVersion(accCfg.CypherURL)
|
||||
// 🔴 `inkstone/arcrun-rag#159`:**一次探測失敗 ≠ 這台知識庫連不上。**
|
||||
//
|
||||
// leo 2026-08-28 的畫面上,`youlin.hsieh.dev` 那行紅字寫「目前連不上這個
|
||||
// 知識庫,查不到版本」——而同一台機器上 curl 打同一支 /health 是 HTTP 200
|
||||
// 亞秒回應。實查當下 status.json:**三個帳號全部** `cloud_check_ok=false`、
|
||||
// 錯誤都是 `dial tcp: ... no such host`,而剛開的 Go 行程(cgo 與純 Go
|
||||
// 兩種 resolver 都試過)解得出來、GET 也 200。
|
||||
// ⇒ 那是這台機器當下的 DNS 抽風,不是知識庫掛了。
|
||||
//
|
||||
// 舊寫法把「這一輪沒查到」直接呈現成「查不到版本」,於是**一次抽風就抹掉
|
||||
// 我們早就知道的事實**。改成:查不到就沿用上一輪查到的版本
|
||||
//(同 CarryForwardActivity 的理由),只有**從來沒查到過**才是真的不知道。
|
||||
// CloudCheckOK 仍照實記這一輪的可達性——那是另一件事,不混在一起。
|
||||
if strings.TrimSpace(cloudVer) == "" {
|
||||
if prevAcc, ok := prevStatus.AccountDetails[accHost]; ok {
|
||||
cloudVer = prevAcc.CloudVersion
|
||||
}
|
||||
}
|
||||
// t215:這個帳號要不要更新——與 portal 版本卡同一套判準(見檔頭)。
|
||||
cloudUpd := EvalCloudUpdate(cloudVer, cloudOK, latestRelease, latestOK)
|
||||
// 第二個參數傳「我們知不知道它的版本」而不是「這一輪連不連得上」:
|
||||
// 版本是**事實**,可達性是**當下狀態**,兩者不是同一個問題。
|
||||
cloudUpd := EvalCloudUpdate(cloudVer, strings.TrimSpace(cloudVer) != "", latestRelease, latestOK)
|
||||
accSt := AccountSyncStatus{
|
||||
LastSync: time.Now().Format(time.RFC3339),
|
||||
CloudVersion: cloudVer,
|
||||
@@ -703,7 +809,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 更新進度不同步),一個帳號通了不代表另一個也通。
|
||||
// 只在走 workers-ai 這條路時掃;選了 Gemini 的人不需要知道這件事。
|
||||
if accCfg.Extractor == "workers-ai" {
|
||||
state := ProbeWorkersAI(accCfg.CypherURL, accCfg.APIKey)
|
||||
state := accCfg.probeWorkersAI()
|
||||
accSt.CloudAIReady = state.Ready
|
||||
accSt.CloudAINote = state.Note
|
||||
if !state.Ready && state.Note != "" {
|
||||
@@ -733,6 +839,19 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
|
||||
totalProgress = totalProgress.Add(rp.Progress)
|
||||
stuckReasons = append(stuckReasons, rp.StuckReasons...)
|
||||
// #44:這一根的樹。**掃壞了(Nodes 空)就不要覆蓋上一輪的好資料**——
|
||||
// 合併時沿用舊的(MergeFolderTreeStore ②),畫面不會突然變成「還沒回報」。
|
||||
knownRoots = append(knownRoots, root)
|
||||
if len(rp.Tree.Nodes) > 0 {
|
||||
folderTrees[root] = rp.Tree
|
||||
}
|
||||
// #140:有話要說才佔畫面(零值=這個資料夾沒有補送中的事)。
|
||||
if rp.Resync.Pending > 0 || rp.Resync.Repaired > 0 || rp.Resync.LastError != "" {
|
||||
if resync == nil {
|
||||
resync = map[string]ResyncStatus{}
|
||||
}
|
||||
resync[root] = rp.Resync
|
||||
}
|
||||
if multi {
|
||||
for i := range r {
|
||||
r[i].Root = root
|
||||
@@ -740,7 +859,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
}
|
||||
for i := range r {
|
||||
r[i].Account = accHost // t104: 標明所屬帳號
|
||||
if cfg.Extractor != "" {
|
||||
if cfg.Extractor != "" && countsAsDocument(r[i]) {
|
||||
switch r[i].Status {
|
||||
case "ingested":
|
||||
accSt.ExtractedOK++
|
||||
@@ -766,6 +885,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
skippedOtherNames = append(skippedOtherNames, p.SkippedOtherNames...)
|
||||
// #104:這一根用了什麼策略、少收了什麼 —— 以前到這裡就被丟掉了
|
||||
// (只有 CLI 的 stderr 講得出來,App 走的這條路一個字都不說)。
|
||||
// #159:這一根的同步現況。與 folderPlans 同一個「掃成了才記」的閘——
|
||||
// 掃壞的那輪 rootProgress 是零值,記下去畫面會把它讀成「0 份、已同步」
|
||||
// =拿我們自己編的答案打勾。沒記的根由下面的沿用機制補上一輪的。
|
||||
folderProgress[root] = rp.Progress
|
||||
folderPlans[root] = FolderPlanStatus{
|
||||
Mode: string(p.Plan.Mode),
|
||||
Reason: p.Plan.Reason,
|
||||
@@ -828,6 +951,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
ExtractorError: extractorError,
|
||||
AccountDetails: accountDetails,
|
||||
Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度
|
||||
Resync: resync, // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾
|
||||
// #153:這一輪等太久的事。空=沒有人在等,畫面上不佔位置。
|
||||
Stalls: cfg.guard.Stalls(),
|
||||
}
|
||||
// G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。
|
||||
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
|
||||
@@ -854,6 +980,21 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if len(folderPlans) > 0 {
|
||||
st.FolderPlans = folderPlans
|
||||
}
|
||||
// #159:逐資料夾的同步現況。這一輪沒掃成的根**沿用上一輪**
|
||||
// (同 MergeFolderTreeStore ② 的理由:畫面不該因為一輪掃壞就說「沒有狀態」);
|
||||
// 已經不在看守清單上的根自然消失——folderProgress 的 key 只可能來自本輪的
|
||||
// knownRoots,移除掉的資料夾不會被沿用回來。
|
||||
for _, root := range knownRoots {
|
||||
if _, ok := folderProgress[root]; ok {
|
||||
continue
|
||||
}
|
||||
if prev, ok := prevStatus.FolderProgress[root]; ok {
|
||||
folderProgress[root] = prev
|
||||
}
|
||||
}
|
||||
if len(folderProgress) > 0 {
|
||||
st.FolderProgress = folderProgress
|
||||
}
|
||||
st.SkippedDocCount = len(skippedSeen)
|
||||
for _, sf := range skippedSeen {
|
||||
st.SkippedDocs = append(st.SkippedDocs, sf)
|
||||
@@ -882,6 +1023,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
|
||||
if cfg.Extractor != "" {
|
||||
for _, r := range results {
|
||||
if !countsAsDocument(r) {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case "ingested":
|
||||
st.ExtractedOK++
|
||||
@@ -898,8 +1042,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
// 使用者才看得到「為什麼」而不只是「幾份」。
|
||||
// 2026-08-07:額度冷卻的 skip 訊息(quotaState.noticeNow().Combined())
|
||||
// 用「會自動恢復」當識別字——同樣要讓使用者看得到原因,不是只看到「幾份」。
|
||||
if strings.Contains(r.Error, "後重試") || strings.Contains(r.Error, "已暫停自動重試") ||
|
||||
strings.Contains(r.Error, "會自動恢復") {
|
||||
if explainsWhySkipped(r.Error) {
|
||||
st.Failures = append(st.Failures, ExtractFail{
|
||||
Path: r.Path,
|
||||
Error: shortError(r.Error),
|
||||
@@ -949,6 +1092,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
|
||||
if serr := SaveSyncStatus(statusPath, st); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
|
||||
}
|
||||
|
||||
// `inkstone/InkStoneCo#44`:本機那份樹快照。
|
||||
// 🔴 **刻意不放進 status.json**:桌面小幫手每秒讀一次 status.json(tick),
|
||||
// 而一棵樹上限 300 個節點、一台機器可能看守好幾個資料夾
|
||||
// ⇒ 併進去等於讓每一秒都去解析幾百 KB 只有「使用者按開樹」那一刻才要用的資料。
|
||||
// 分成兩個檔,讀的人各取所需(小幫手只在展開時才讀這一份)。
|
||||
treePath := FolderTreeStorePath(cfg.Manifest)
|
||||
prevTrees, _ := LoadFolderTreeStore(treePath) // 讀不到=沒有上一輪,零值可用
|
||||
if terr := SaveFolderTreeStore(treePath, MergeFolderTreeStore(prevTrees, folderTrees, knownRoots, now)); terr != nil {
|
||||
fmt.Fprintf(os.Stderr, "資料夾結構寫入失敗(不擋看守):%v\n", terr)
|
||||
}
|
||||
}
|
||||
|
||||
return results, exit, lastPayload
|
||||
@@ -1027,11 +1181,17 @@ func drainPendingTakedowns(
|
||||
}
|
||||
return results, exit
|
||||
}
|
||||
// #153:同一條撤除路徑服務兩件事,而使用者眼中它們不是同一件——
|
||||
// 「我刪了一個檔」跟「我把整個資料夾收回來」卡住時該說的話不一樣。
|
||||
step := stepTakedown
|
||||
if resultType == "folder_takedown" {
|
||||
step = stepRetire
|
||||
}
|
||||
for oldPath, pageName := range m.PendingTakedowns {
|
||||
pace()
|
||||
res := DirectResult{Type: resultType, Path: oldPath}
|
||||
mach := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(step, cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
"page_name": pageName,
|
||||
"path": oldPath,
|
||||
"library": cfg.libraryFor(absRoot),
|
||||
@@ -1204,6 +1364,14 @@ func accountsConnected(cfg *DirectConfig) bool {
|
||||
type rootProgress struct {
|
||||
Progress SyncProgress // 這一根的 Total/Done/Pending/Stuck(Unreadable 由呼叫端補,見 progress.go)
|
||||
StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類
|
||||
// Resync=這一根的「雲端補送」現況(#140)。**從 manifest 現況重算**,不是本輪計數
|
||||
// ——後者在沒事做的那輪會歸零,那正是 2026-08-05 leo 實撞的「明明做完了畫面卻寫等待中」。
|
||||
// 沒事=零值,呼叫端不寫進 status.json(不製造常駐噪音)。
|
||||
Resync ResyncStatus
|
||||
// Tree=這一根這一輪算出來的資料夾樹(`inkstone/InkStoneCo#44`,桌面小幫手那半)。
|
||||
// 🔴 **就是送上雲端的那一棵**(BuildFolderTree 的產物原件),不是為了畫面另算一份。
|
||||
// 呼叫端把它落地成 folder-trees.json,小幫手離線也攤得開(理由見 foldertree.go 檔尾)。
|
||||
Tree FolderTree
|
||||
}
|
||||
|
||||
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
|
||||
@@ -1255,6 +1423,72 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
if err != nil {
|
||||
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
|
||||
}
|
||||
// 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。
|
||||
// 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、
|
||||
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
|
||||
// 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。
|
||||
// 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映
|
||||
// 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。
|
||||
//
|
||||
// (#140:定義位置從「Scan() 之後」往上搬到這裡,因為雲端對帳跑在 Scan() 之前
|
||||
// 也要落盤——拔掉的章沒存進磁碟,process 被殺掉就等於沒對過帳。內容一字未動。)
|
||||
saveManifest := func() {
|
||||
if dryRun {
|
||||
return
|
||||
}
|
||||
if serr := m.Save(absManifest); serr != nil {
|
||||
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()})
|
||||
exit = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 雲端對帳(`inkstone/arcrun-rag#140`,2026-08-26)——**必須在 Scan() 之前**。
|
||||
// 它做的唯一一件事是把「雲端已經沒有了」的 ingested 章拔掉;拔完之後
|
||||
// 下面那個 Scan() 就會因為 `orig[p].IngestedHash == ""` 自然補一發 added 事件
|
||||
// (scan.go 步驟 4 的既有語意「上輪偵測過但 ingest 未成功 → 重試」)
|
||||
// ⇒ 補送走的是**既有的**萃取路,既有的單輪上限/失敗退避/額度冷卻全部照舊生效。
|
||||
// 放在 Scan() 之後就得再等一輪才會動,且要另外發明一條送件路。見 cloud_audit.go。
|
||||
auditErr := ""
|
||||
if ar := auditCloudLedger(cfg, absRoot, m, dryRun, runNow); ar != nil {
|
||||
auditErr = ar.Err
|
||||
if ar.Voided > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "resync", Path: absRoot, Status: "noticed",
|
||||
Error: fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(對帳了 %d 份,知識庫可能重裝過),已排進佇列自動補送", ar.Voided, ar.Checked),
|
||||
})
|
||||
}
|
||||
if ar.Err != "" {
|
||||
// 上游錯誤原文照顯示(leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,
|
||||
// 不然就會變成我的錯誤」)。對帳失敗**不設 exit**:它是保險絲不是主流程,
|
||||
// 連不上雲端時檔案同步照常走。
|
||||
results = append(results, DirectResult{
|
||||
Type: "resync", Path: absRoot, Status: "noticed",
|
||||
Error: "無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試):" + ar.Err,
|
||||
})
|
||||
}
|
||||
saveManifest() // 拔掉的章與對帳時間當下就落盤(斷點續傳同款)
|
||||
}
|
||||
|
||||
// 🔴 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)——一次性、修完蓋章。
|
||||
// 舊卡的出處寫的是 `../<檔名>`(daemon 自己的目錄結構),AI 照著答、使用者走不到。
|
||||
// 修產生端只治得了新卡,票上寫死「不能只修新的」⇒ 這裡把本機既有的卡重畫那一塊
|
||||
// 並原樣重推(零 LLM,workflow 進門先刪同名舊 blocks ⇒ 取代不疊加)。見 sourcerepair.go。
|
||||
if sr := repairCardSourceBlocks(cfg, absRoot, m, dryRun, false, runNow); sr != nil {
|
||||
if sr.Repushed > 0 {
|
||||
results = append(results, DirectResult{
|
||||
Type: "resync", Path: absRoot, Status: "noticed",
|
||||
Error: fmt.Sprintf("已修好 %d 份筆記的「原文位置」寫法(從前寫的是程式內部路徑,現在會告訴你在哪台機器、哪個知識庫、庫內哪一個檔)", sr.Repushed),
|
||||
})
|
||||
}
|
||||
if sr.Err != "" {
|
||||
results = append(results, DirectResult{
|
||||
Type: "resync", Path: absRoot, Status: "noticed",
|
||||
Error: "更新「原文位置」時有一份沒成功(不影響同步,稍後自動再試):" + sr.Err,
|
||||
})
|
||||
}
|
||||
saveManifest()
|
||||
}
|
||||
|
||||
// 2026-08-07 task 3:Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
|
||||
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
|
||||
// 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的
|
||||
@@ -1291,20 +1525,27 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
|
||||
now := runNow.Unix()
|
||||
|
||||
// 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。
|
||||
// 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、
|
||||
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
|
||||
// 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。
|
||||
// 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映
|
||||
// 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。
|
||||
saveManifest := func() {
|
||||
if dryRun {
|
||||
return
|
||||
}
|
||||
if serr := m.Save(absManifest); serr != nil {
|
||||
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()})
|
||||
exit = 1
|
||||
}
|
||||
// 🔴 `inkstone/arcrun-rag#153` 第二輪:**樹算出來就立刻落地本機,排在所有雲端呼叫之前。**
|
||||
//
|
||||
// 為什麼非搬到這裡不可(2026-08-28 用 `ARCRUN_TRACE=1` 量出來的,不是推測):
|
||||
// 對一個**健康**的雲端實例,每一發呼叫實測 24〜33 秒
|
||||
//(資料夾總覽 26.3s/目錄索引 24.0・24.1・23.5s/送出一份筆記 33.2・44.6s)。
|
||||
// 一個 12 層、10 個檔的資料夾,一輪就是十幾分鐘;leo 真正的設定是三個帳號、
|
||||
// 好幾千個檔,一輪是好幾小時。**沒有任何一發超過 300 秒,所以逾時與斷路器都不會動**
|
||||
// ——這不是卡住,是這一輪還沒輪到寫它。
|
||||
//
|
||||
// 而這棵樹是**純本機、秒級**算出來的(就在上面那次 Scan 的產物上),
|
||||
// 它沒有理由排在十幾分鐘的雲端佇列後面。搬到這裡之後,使用者的資料夾結構
|
||||
// 在開跑幾秒內就是對的,即使這一輪還要再跑一小時。
|
||||
//
|
||||
// 內容一個位元都沒變:BuildFolderTree 吃的是 payload.DirStats/m.Entries/
|
||||
// payload.AllExcludedDirs/plan,而從 Scan() 到這裡之間**沒有任何東西動過 m.Entries**
|
||||
//(動它的是下面「removed 暫時放回」那段,本來就在原位置之後)。
|
||||
// 送上雲端那一發(syncFolderTree)**維持在原來的位置**,用的就是這一棵。
|
||||
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
|
||||
payload.AllExcludedDirs, plan, runNow)
|
||||
if !dryRun {
|
||||
PublishFolderTreeNow(cfg.Manifest, root, tree, runNow)
|
||||
}
|
||||
|
||||
// 結構先行(InkStoneCo#43,2026-08-15):掃描一結束(純本機、免費、秒級)就先把
|
||||
@@ -1334,6 +1575,21 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
}
|
||||
}
|
||||
|
||||
// InkStoneCo#44 線 A(leo 2026-08-17):把「這個資料夾長什麼樣、每一層同步了幾份/
|
||||
// 總共幾份」送上 portal。與上面的總覽卡刻意分開——那張是給檢索用的 markdown 卡,
|
||||
// 這棵是給畫面用的結構化資料,消費者不同(理由全文見 foldertree.go 檔頭)。
|
||||
//
|
||||
// 位置與總覽卡同一段的三個理由完全通用(不等 LLM/在額度閘之外/反映現況),
|
||||
// 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**(arcrun-rag#106 的情境本身),
|
||||
// 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時
|
||||
// 內容雜湊自然擋住重送,不需要事件當第二道閘。
|
||||
if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil {
|
||||
results = append(results, *treeRes)
|
||||
if treeRes.Status != "planned" {
|
||||
saveManifest() // 記住 folder_tree_hash/失敗退避
|
||||
}
|
||||
}
|
||||
|
||||
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
|
||||
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
|
||||
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
|
||||
@@ -1366,13 +1622,31 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// - 上限:巨量積壓(實據 27,164 檔)不該一輪湧完;超過上限的事件本輪不碰,
|
||||
// manifest 未標 ingested ⇒ 下一輪 Scan() 自然重新出現(且若使用者這期間
|
||||
// 寫了新檔,新檔的 mtime 更新,下一輪排序會插到最前面,不會被積壓卡住)。
|
||||
//
|
||||
// 🔴 arcrun-rag#104 comment 4480(t217,2026-08-27):cap 過去套在「排序後的原始
|
||||
// 清單」上,而不是套在「這輪真的會被嘗試」的事件上。退避中/已達重試上限的檔案
|
||||
// 不會因為正在退避就往後排(mtime 沒變、排序不變)⇒ 只要前 perRunCap 名一直是
|
||||
// 同一批持續失敗的檔案,它們就會**永久佔滿名額**,排在後面的事件不管跑幾輪都
|
||||
// 排不到——這正是 leo 實測到的「1691→1880→1936 筆從不減少」,佇列本身就是問題。
|
||||
// 修法:cap 改套在 partitionRetryEligible 分出來的 ready(會真的嘗試)事件上;
|
||||
// 退避中的事件(waiting)不佔嘗試名額,讓排在它們後面、真正還沒被嘗試過的事件
|
||||
// 有機會遞補上來。waiting 仍要展示(診斷用、不是安靜消失),但同樣設一個上限,
|
||||
// 避免巨量積壓(實據 KB 資料夾 ~1900 筆退避中)把單輪結果與 status.json 灌爆。
|
||||
orderedEvents := sortEventsNewestFirst(absRoot, payload.Events)
|
||||
perRunCap := cfg.effectiveMaxEventsPerRun()
|
||||
readyEvents, waitingEvents := partitionRetryEligible(m, orderedEvents, now, cfg.ForceSync, qs.inCooldown(runNow))
|
||||
|
||||
deferredCount := 0
|
||||
if len(orderedEvents) > perRunCap {
|
||||
deferredCount = len(orderedEvents) - perRunCap
|
||||
orderedEvents = orderedEvents[:perRunCap]
|
||||
if len(readyEvents) > perRunCap {
|
||||
deferredCount = len(readyEvents) - perRunCap
|
||||
readyEvents = readyEvents[:perRunCap]
|
||||
}
|
||||
visibleWaiting := waitingEvents
|
||||
if len(visibleWaiting) > perRunCap {
|
||||
deferredCount += len(visibleWaiting) - perRunCap
|
||||
visibleWaiting = visibleWaiting[:perRunCap]
|
||||
}
|
||||
orderedEvents = append(readyEvents, visibleWaiting...)
|
||||
|
||||
for _, ev := range orderedEvents {
|
||||
switch ev.Type {
|
||||
@@ -1392,6 +1666,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 🔴 #153:這個**帳號**這一輪已經被判定沒有回應 ⇒ 連試都不試。
|
||||
// 與上面的額度冷卻同一層、同一個理由:這不是這個檔的問題,
|
||||
// 記進它的病歷(FailCount/退避階梯)會讓一次雲端沒回應,
|
||||
// 變成一整批檔案「已放棄自動重試」——那是把別人的停機算在使用者頭上。
|
||||
// 沒有這道閘的話,一個沒有回應的帳號會讓這一輪繼續逐檔去撞,
|
||||
// 每撞一次就是一個 Budget,25 個檔就是幾十分鐘。
|
||||
if note := cfg.unreachableNote(); note != "" {
|
||||
res.Status = "skipped"
|
||||
res.Error = note
|
||||
results = append(results, res)
|
||||
continue
|
||||
}
|
||||
// 🔴 t195 止血點:這個檔剛失敗過且還在退避窗口內 → 這輪跳過。
|
||||
// 沒有這道閘時的實測災情:`小果被AFTEE詐貸.pdf` 因雲端 401 失敗,
|
||||
// 每輪重掃又被當成新檔 ⇒ **1387 輪、跨 11 小時**,且它排在佇列前面,
|
||||
@@ -1423,6 +1709,15 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
pace() // 2026-08-07:每次要觸發雲端(萃取/POST)之前先節流一下
|
||||
if cfg.Extractor != "" {
|
||||
// 四步定稿:本地萃卡 → 每張卡 POST rag_ingest_card(原文不出機)
|
||||
// 🔴 `inkstone/Arcrun#167`:卡片的「### 出處」要寫得出「哪台機器 /
|
||||
// 哪個庫 / 庫內什麼路徑」,所以萃卡前先把這三件備好交給塑形層。
|
||||
// 三件與下面 cardBody 送雲端的 machine/library/path 是**同一組值**
|
||||
// ——卡上寫的與雲端存的從此對得起來(不再各說各話)。
|
||||
cardOrigin := SourceOrigin{
|
||||
MachineLabel: cfg.machineIdentity().Label,
|
||||
Library: cfg.libraryFor(absRoot),
|
||||
LibraryPath: ev.Path,
|
||||
}
|
||||
var cards []string
|
||||
var xerr error
|
||||
switch cfg.Extractor {
|
||||
@@ -1435,9 +1730,24 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// ⇒ 探測在 RunDirectOnce(ProbeWorkersAI),結果寫進 status.json,
|
||||
// 托盤那行「狀態:」直接告訴用戶該做什麼。
|
||||
// 靜默退回會讓用戶**永遠不知道自己的雲端還沒更新**——正是要避免的黑箱。
|
||||
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path)
|
||||
// #153:萃取也是一發會等很久的網路呼叫,而 workers-ai 打的正是
|
||||
// **使用者自己的那台雲端實例**——跟上面那些收口是同一台。
|
||||
// 它有自己的 client timeout,但沒有人在數「這個帳號已經連續幾發
|
||||
// 等不到回覆」⇒ 漏掉這一格的話,單輪上限 25 個檔會變成 25 次
|
||||
// 各自的等待,同一輪照樣走不完。
|
||||
//
|
||||
// 🔴 gemma 那條**刻意不掛**:它打的是 Google,不是使用者的知識庫。
|
||||
// 掛上去的話,Google 慢會被算成「你的知識庫沒有回應」——
|
||||
// 誤導的訊息比沒有訊息更貴(會害人往錯的方向查)。
|
||||
xgate := cfg.openGate(stepExtractDoc)
|
||||
cards, xerr = ExtractWithWorkersAI(cfg.CypherURL, cfg.APIKey, absRoot, ev.Path, cardOrigin)
|
||||
xgate.release()
|
||||
if xerr == nil {
|
||||
xgate.ok()
|
||||
}
|
||||
xerr = xgate.record(xerr) // 只有「等到超時」會被記帳,其餘錯誤原樣往下走
|
||||
case "gemma":
|
||||
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path)
|
||||
cards, xerr = ExtractWithGemma(cfg.GeminiAPIKey, cfg.LLMModel, absRoot, ev.Path, cardOrigin)
|
||||
default:
|
||||
// t176:claude 路先不支援(RunDirectOnce 開頭已正規化)。
|
||||
// 走到這裡代表 config 有沒見過的值——誠實報錯,不要靜默跳過(禁假綠)。
|
||||
@@ -1516,7 +1826,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
cardBody["quality"] = "low"
|
||||
cardBody["quality_warnings"] = warns
|
||||
}
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.CardIngestWF), cardBody)
|
||||
status, _, perr := cfg.postJSON(stepIngestCard, cfg.triggerURL(cfg.CardIngestWF), cardBody)
|
||||
res.HTTPStatus = status
|
||||
if perr != nil {
|
||||
res.Status, res.Error = "failed", perr.Error()
|
||||
@@ -1528,6 +1838,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
res.Status = "ingested"
|
||||
// 記下是誰萃的(t73/leo 07-27):換萃取器時才分辨得出哪些卡是舊的。
|
||||
m.MarkIngestedBy(ev.Path, ev.SourceHash, now, cfg.Extractor)
|
||||
// 🔴 #140:`cards` 為空=該檔被判「無可萃取概念」,這一輪**一張卡都沒上雲**。
|
||||
// 不記下來的話,雲端對帳每天都會查到「雲端沒有它」⇒ 每天重萃一次、
|
||||
// 永遠停不下來,而且每次都燒一份 AI 額度。
|
||||
if len(cards) == 0 {
|
||||
m.MarkNoCloudCard(ev.Path)
|
||||
}
|
||||
qs.DailyCount++ // 2026-08-07:今天的成就數(額度訊息「今天已經幫你整理了 N 份」用)
|
||||
} else {
|
||||
// t195:記下失敗並排定退避,否則下輪又把它當新檔重試
|
||||
@@ -1553,7 +1869,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// ⚠️ 雲端這支 workflow 本輪**沒有跟著改**(youlin stage 上根本沒部署它,
|
||||
// 現役是 rag_ingest_card)——它會忽略這兩個欄位,行為與從前一字不差。
|
||||
machDirect := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.IngestWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(stepIngestDoc, cfg.triggerURL(cfg.IngestWF), map[string]any{
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
"content": string(content),
|
||||
@@ -1590,7 +1906,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
// 連坐殺掉 B 機器同名檔」,而多補一維會改變既有的撤除命中範圍——
|
||||
// 那是另一件事,要另外驗(本輪不驗的不做)。
|
||||
machRm := cfg.machineIdentity()
|
||||
status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
status, _, perr := cfg.postJSON(stepTakedown, cfg.triggerURL(cfg.RemovedWF), map[string]any{
|
||||
"page_name": pageNameOf(ev.Path),
|
||||
"path": ev.Path,
|
||||
"machine": machRm.ID,
|
||||
@@ -1669,7 +1985,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
|
||||
// t210:manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
|
||||
// 原地數一次就是對的(同 SkippedDocCount 那套「現況快照」邏輯,不必另外維護計數器)。
|
||||
rp := rootProgress{Progress: m.Progress()}
|
||||
rp := rootProgress{Progress: m.Progress(), Tree: tree}
|
||||
for _, e := range m.Entries {
|
||||
if e != nil && e.FailCount >= MaxFailBeforeSkip {
|
||||
// LastError 原文交給呼叫端彙總後過 ClassifyFailure——分類判斷只住那一個接縫,
|
||||
@@ -1678,6 +1994,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
|
||||
rp.StuckReasons = append(rp.StuckReasons, e.LastError)
|
||||
}
|
||||
}
|
||||
// #140:同一套「現況快照」邏輯——補送進度也是原地數出來的,不是本輪計數器。
|
||||
if pending, repaired := ResyncSummary(m, runNow); pending > 0 || repaired > 0 || auditErr != "" {
|
||||
rp.Resync = ResyncStatus{
|
||||
Pending: pending,
|
||||
Repaired: repaired,
|
||||
LastError: auditErr, // 真因原文,不改寫(leo 2026-08-06)
|
||||
Note: resyncNote(pending, repaired, auditErr),
|
||||
}
|
||||
if m.CloudAuditAt > 0 {
|
||||
rp.Resync.CheckedAt = time.Unix(m.CloudAuditAt, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return results, exit, payload, rp
|
||||
}
|
||||
|
||||
@@ -1717,20 +2045,22 @@ func runDirect(args []string) int {
|
||||
// ⇒ 開工前先印一筆 `phase:"start"`,托盤收到就顯示「同步中…」,
|
||||
// 收到 `phase:"done"` 再切回「看守中」。
|
||||
// 形狀相容:兩筆都有 `at`,舊版托盤只會多算一次 round,不會壞掉。
|
||||
startOut, _ := json.MarshalIndent(struct {
|
||||
//
|
||||
// 🔴 #153:這兩筆改走 printJSONLine——「還在等」的播報跑在另一條 goroutine 上,
|
||||
// 而 done 那筆可能有幾十 KB。共用同一把鎖,兩邊才不會把彼此的 JSON 切成兩半
|
||||
//(切壞一次,supervisor 的 decoder 就再也讀不到這個行程的任何一筆,見 stallguard.go)。
|
||||
printJSONLine(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
}{time.Now().Format(time.RFC3339), "start"}, "", " ")
|
||||
fmt.Println(string(startOut))
|
||||
}{time.Now().Format(time.RFC3339), "start"})
|
||||
|
||||
results, exit, _ := RunDirectOnce(cfg, *dryRun)
|
||||
out, _ := json.MarshalIndent(struct {
|
||||
printJSONLine(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
Folders []string `json:"folders"`
|
||||
Results []DirectResult `json:"results"`
|
||||
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
}{time.Now().Format(time.RFC3339), "done", cfg.Folders(), results})
|
||||
return exit
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -55,6 +55,12 @@ func TestDirectExtractorModeE2E(t *testing.T) {
|
||||
// 假 cypher:收 rag_ingest_card、驗 payload、記帳
|
||||
var posted []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹走 portal 登記端點(不是 workflow webhook),
|
||||
// 它不是「卡片」也不含任何原文 ⇒ 不算進 posted,也不算打錯端點。
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/webhooks/named/demo/rag_ingest_card/trigger") {
|
||||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||||
}
|
||||
@@ -281,9 +287,12 @@ func TestMultiAccountInheritsExtractor(t *testing.T) {
|
||||
|
||||
var hitCard, hitDirect bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 🔴 這裡以前寫的是 `else { hitDirect = true }`——「不是卡片端點就當成直送端點」。
|
||||
// InkStoneCo#44 加了第三個端點(資料夾樹)之後,那個 else 就開始說謊。
|
||||
// 改成指名要驗的那條路:本測要守的契約是「**原文不出機**」=不准打 rag_ingest_direct。
|
||||
if strings.Contains(r.URL.Path, "rag_ingest_card") {
|
||||
hitCard = true
|
||||
} else {
|
||||
} else if strings.Contains(r.URL.Path, "rag_ingest_direct") {
|
||||
hitDirect = true
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
|
||||
@@ -133,8 +133,10 @@ func TestRunDirectOnceMultiRootDryRun(t *testing.T) {
|
||||
if r.Status != "planned" {
|
||||
t.Fatalf("dry-run 事件應為 planned:%+v", r)
|
||||
}
|
||||
if r.Type == "inventory" {
|
||||
continue // 結構先行:每根各有一張總覽卡(planned),檔案事件另計
|
||||
if r.Type == "inventory" || r.Type == "folder_tree" {
|
||||
// 每根各有一張總覽卡與一棵資料夾樹(都是 planned),檔案事件另計。
|
||||
// 兩者都不是檔案事件:一個是結構先行的知識卡,一個是 portal 畫面的樹。
|
||||
continue
|
||||
}
|
||||
byRoot[r.Root] = append(byRoot[r.Root], r.Path)
|
||||
}
|
||||
|
||||
@@ -107,3 +107,41 @@ func sortEventsNewestFirst(absRoot string, events []Event) []Event {
|
||||
func resumeAfterCapMessage(remaining int) string {
|
||||
return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining)
|
||||
}
|
||||
|
||||
// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組:
|
||||
// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、
|
||||
// 帳號沒在額度冷卻中。
|
||||
// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達
|
||||
// MaxFailBeforeSkip、或整個帳號正在額度冷卻。
|
||||
//
|
||||
// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的
|
||||
// case "removed"),一律歸 ready,維持既有行為不變。
|
||||
//
|
||||
// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480,t217):
|
||||
// 舊版直接對排序後的原始清單套用 perRunCap(`orderedEvents[:perRunCap]`)。
|
||||
// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap
|
||||
// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪
|
||||
// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到,
|
||||
// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因:
|
||||
// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。
|
||||
//
|
||||
// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額,
|
||||
// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。
|
||||
func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) {
|
||||
for _, ev := range events {
|
||||
if ev.Type == "removed" {
|
||||
ready = append(ready, ev)
|
||||
continue
|
||||
}
|
||||
if coolingDown {
|
||||
waiting = append(waiting, ev)
|
||||
continue
|
||||
}
|
||||
if m.ShouldRetry(ev.Path, now, force) {
|
||||
ready = append(ready, ev)
|
||||
} else {
|
||||
waiting = append(waiting, ev)
|
||||
}
|
||||
}
|
||||
return ready, waiting
|
||||
}
|
||||
|
||||
+138
-1
@@ -106,6 +106,11 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var order []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹走 portal 登記端點,body 沒有 page_name。
|
||||
// 不先擋掉,下面那行型別斷言會 panic,而測試會變成掛住 8 分鐘(見該 helper 的說明)。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
@@ -196,10 +201,137 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4) 退避中的檔案不該永久佔滿單輪名額(arcrun-rag#104 comment 4480,t217)──
|
||||
//
|
||||
// 背景:leo 實測 leo21c 帳號的積壓「1691→1880→1936 筆從不減少」,
|
||||
// 「已經好久沒有加過任何檔案,哪來的這些筆數?那就是之前卡住的,就是你要解決的問題」
|
||||
// 「佇列就是問題本身」。
|
||||
//
|
||||
// 根因:舊版把 perRunCap 套在「排序後的原始清單」上。mtime 最新的幾個檔如果持續
|
||||
// 失敗(進入退避),它們不會因為在退避就往後排,於是每一輪都繼續佔著最前面的
|
||||
// perRunCap 個名額——即使這一輪根本不會被嘗試,只是被跳過。排在它們後面、
|
||||
// 從沒被嘗試過的健康檔案因此永遠排不到,不管跑幾輪都一樣。
|
||||
//
|
||||
// 這個測試重現該情境:3 個 mtime 最新的檔一直失敗(模擬持續性錯誤,例如票上量到的
|
||||
// 雲端 subrequest 上限或萃取回傳格式錯誤),5 個 mtime較舊、原本會成功的健康檔案
|
||||
// 排在它們後面。單輪上限=3。
|
||||
//
|
||||
// - 第一輪:全部檔案都還沒失敗過(FailCount=0),cap 選中 mtime 最新的 3 個
|
||||
// (也就是那 3 個會一直失敗的檔),全部失敗,記下退避(下次重試在 60 秒後)。
|
||||
// - 第二輪(緊接著呼叫,真實時間遠不到 60 秒):那 3 個檔仍在退避中。
|
||||
// 舊版行為:cap 依然套在原始排序上,選中的還是同一批退避中的檔案 ⇒
|
||||
// 這一輪 0 個健康檔案被嘗試,健康檔案永遠排不到。
|
||||
// 修好後的行為:退避中的檔案被分流到 waiting、不佔 ready 的名額,
|
||||
// cap 改套用在 ready 上 ⇒ 健康檔案的前 3 名遞補上來,這一輪就會被嘗試並成功。
|
||||
func TestDirect_StarvedBacklog_HealthyFilesEventuallyGetATurn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// 3 個「一直失敗」的檔,mtime 最新(若 bug 還在,會永久佔滿 cap)。
|
||||
blockers := []string{"blocker-a", "blocker-b", "blocker-c"}
|
||||
for i, n := range blockers {
|
||||
writeFile(t, root, n+".md", "持續失敗的內容 "+n, baseTime.Add(time.Duration(10+i)*time.Minute))
|
||||
}
|
||||
// 5 個「健康」的檔,mtime 較舊(排在後面,理應遞補上來)。
|
||||
healthy := []string{"h5", "h4", "h3", "h2", "h1"}
|
||||
for i, n := range healthy {
|
||||
writeFile(t, root, n+".md", "健康內容 "+n, baseTime.Add(time.Duration(4-i)*time.Minute))
|
||||
}
|
||||
|
||||
failingPages := map[string]bool{"blocker-a": true, "blocker-b": true, "blocker-c": true}
|
||||
var mu sync.Mutex
|
||||
var succeededPages []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
pageName, _ := m["page_name"].(string)
|
||||
if strings.HasPrefix(pageName, "資料夾總覽") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
if failingPages[pageName] {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":"boom"}`))
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
succeededPages = append(succeededPages, pageName)
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"parts": []map[string]any{{"text": cardFixture("卡", "測試")}}},
|
||||
}},
|
||||
})
|
||||
})()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
MaxEventsPerRun: 3,
|
||||
}
|
||||
|
||||
// 第一輪:8 個檔都還沒失敗過,cap 選中 mtime 最新的 3 個(blocker-a/b/c),全部失敗。
|
||||
results1, _, _ := RunDirectOnce(cfg, false)
|
||||
if got := ingestedPaths(results1); len(got) != 0 {
|
||||
t.Fatalf("第一輪不該有任何成功(cap 選中的 3 個全會失敗),got %v", got)
|
||||
}
|
||||
var failedCount int
|
||||
for _, r := range results1 {
|
||||
if r.Status == "failed" {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 3 {
|
||||
t.Fatalf("第一輪應該有 3 筆真的被嘗試且失敗(blocker-a/b/c),got %d:%+v", failedCount, results1)
|
||||
}
|
||||
|
||||
// 第二輪:緊接著呼叫(真實時間遠不到 60 秒退避窗口)。blocker-a/b/c 仍在退避中。
|
||||
results2, _, _ := RunDirectOnce(cfg, false)
|
||||
ingested2 := ingestedPaths(results2)
|
||||
if len(ingested2) != 3 {
|
||||
t.Fatalf("🔴 第二輪應該有 3 個健康檔案遞補上來被嘗試並成功——"+
|
||||
"如果這裡是 0,代表退避中的 blocker-a/b/c 又佔滿了本輪名額,"+
|
||||
"健康檔案永遠排不到(這正是 leo 實測「佇列從不減少」的那個 bug)。got %d:%+v",
|
||||
len(ingested2), results2)
|
||||
}
|
||||
wantSecond := []string{"h5.md", "h4.md", "h3.md"}
|
||||
for i, w := range wantSecond {
|
||||
if ingested2[i] != w {
|
||||
t.Fatalf("第二輪順序=%v,want %v(健康檔案仍照 mtime 新到舊遞補)", ingested2, wantSecond)
|
||||
}
|
||||
}
|
||||
// blocker-a/b/c 這一輪不該再被真的嘗試(還在退避中)——它們只會以「skipped」出現。
|
||||
for _, r := range results2 {
|
||||
if r.Path == "blocker-a.md" || r.Path == "blocker-b.md" || r.Path == "blocker-c.md" {
|
||||
if r.Status != "skipped" {
|
||||
t.Fatalf("退避中的 %s 這一輪不該被真的嘗試,got status=%s", r.Path, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(succeededPages) != 3 {
|
||||
t.Fatalf("雲端應該收到 3 筆健康卡片,got %d: %v", len(succeededPages), succeededPages)
|
||||
}
|
||||
}
|
||||
|
||||
func ingestedPaths(results []DirectResult) []string {
|
||||
var out []string
|
||||
for _, r := range results {
|
||||
if r.Status == "ingested" && r.Type != "inventory" { // 結構先行:總覽卡另計
|
||||
// 只數「檔案事件」。總覽卡(inventory)與資料夾樹(folder_tree,InkStoneCo#44)
|
||||
// 都是每輪的結構回報,不是使用者的檔案——同 countsAsDocument 的那條線。
|
||||
if r.Status == "ingested" && r.Type != "inventory" && r.Type != "folder_tree" {
|
||||
out = append(out, r.Path)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +362,11 @@ func TestDirect_ResumeAfterInterruption(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var posted []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹的 body 沒有 path ⇒ 不擋掉的話會被記成一筆空字串,
|
||||
// 本測就會看到「送了 4 次」而誤判斷點續傳失效。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
|
||||
+16
-1
@@ -123,7 +123,22 @@ func snapshotCards(absRoot string) map[string]fileState {
|
||||
for _, relDir := range []string{cardsRelDir, vaultCardsRelDir} {
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
|
||||
_ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || filepath.Ext(p) != ".md" {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// 🔴 inkstone/Arcrun#134(2026-08-28):`.wiki/` 整棵跳過。
|
||||
// 那是規範定的卡片正式落點,不是「位置不對的卡」——而 enforceCardMarks
|
||||
// 會把這裡掃到的**新**檔搬去 cardRelFor(搬出隱藏目錄、加 arcrun- 前綴),
|
||||
// 也就是把產物送回 Scan 看得見的地方,下一輪被當新原稿再萃一次。
|
||||
// 829da02 的 `before[rel]` 只擋得住「本來就在的檔」,擋不住這一輪剛寫的卡。
|
||||
// 與 tidy.go collectCardItems 同一個洞,兩支都補才堵得住。
|
||||
if info.IsDir() {
|
||||
if p != base && strings.HasPrefix(info.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(p) != ".md" {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(absRoot, p)
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ func cleanGemmaCard(text, pageName string) string {
|
||||
|
||||
// ExtractWithGemma 讀原稿 → 呼 Gemini 萃卡 → 卡片落地 system-dev/wiki/cards/。
|
||||
// 回傳產出的卡片相對路徑(單檔一卡)。
|
||||
func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) {
|
||||
func ExtractWithGemma(apiKey, model, absRoot, relPath string, origin SourceOrigin) ([]string, error) {
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("gemma 萃取路需要 gemini_api_key(config)")
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
|
||||
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now())
|
||||
if berr != nil {
|
||||
return nil, berr
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestExtractWithGemmaThinkingModel(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
})()
|
||||
cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md")
|
||||
cards, err := ExtractWithGemma("k123", "gemma-test", root, "會議記錄.md", testOrigin())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -79,14 +79,14 @@ func TestExtractWithGemmaThoughtOnly(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
})()
|
||||
if _, err := ExtractWithGemma("k", "m", root, "x.md"); err == nil {
|
||||
if _, err := ExtractWithGemma("k", "m", root, "x.md", testOrigin()); err == nil {
|
||||
t.Fatal("thought-only 應報錯")
|
||||
}
|
||||
}
|
||||
|
||||
// 缺 key=引導訊息。
|
||||
func TestExtractWithGemmaNoKey(t *testing.T) {
|
||||
if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md"); err == nil {
|
||||
if _, err := ExtractWithGemma("", "m", t.TempDir(), "x.md", testOrigin()); err == nil {
|
||||
t.Fatal("缺 key 應報錯")
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) {
|
||||
|
||||
pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root)
|
||||
|
||||
cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
|
||||
cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) {
|
||||
})
|
||||
})()
|
||||
|
||||
_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel)
|
||||
_, err := ExtractWithGemma("k123", "gemma-test", root, srcRel, testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("目標位置被別人佔用時應報錯,不得無聲覆蓋")
|
||||
}
|
||||
|
||||
+60
-2
@@ -51,10 +51,51 @@ import (
|
||||
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
|
||||
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
// maxWorkersAIExtractBytes=送進雲端萃取的純文字上限(位元組)。
|
||||
//
|
||||
// 🔴 為什麼要有這道閘(2026-08-26 實測 `InkStoneCo`,非推測):
|
||||
// 這條路沒有任何長度判斷——整份原稿原封不動塞進 prompt。leo 的
|
||||
// `system-dev/wiki/mistakes.md`(562 KB)、`status.md`(401 KB)、
|
||||
// `status-archive-2026-08.md`(511 KB)因此每一輪都撞同一面牆,
|
||||
// 而使用者看到的是這串**沒有人讀得懂的東西**:
|
||||
//
|
||||
// 本地萃取失敗:雲端萃取失敗(HTTP 502):Workers AI 執行失敗:8007:
|
||||
// {"error":{"message":"This model's maximum context length is 131000 tokens…
|
||||
//
|
||||
// ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列)
|
||||
//
|
||||
// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。
|
||||
//
|
||||
// 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報
|
||||
// 「prompt contains at least 122,xxx tokens」,而可用輸入是
|
||||
// 131,000 − 8,192(輸出) ≈ 122,800 ⇒ 中文原稿約 3.3 位元組/token。
|
||||
// 300 KB ≈ 9 萬 token,留了三成餘裕給提示詞本身與英數混排的變異。
|
||||
//
|
||||
// ⚠️ 這個上限只綁**這條路**(雲端 llama-4-scout 的 131k 視窗)。
|
||||
// gemma 路走 Gemini、視窗大一個數量級,不受此限——判準跟著模型走,
|
||||
// 不做成全域常數,免得換模型時有人以為它是產品規格。
|
||||
const maxWorkersAIExtractBytes = 300_000
|
||||
|
||||
// tooBigForWorkersAI 回傳「這份原稿太大,這條路讀不完」的人話理由;
|
||||
// 沒超過回空字串。
|
||||
//
|
||||
// 🔴 訊息是產品文案不是 debug 字串:要講**多大**、**為什麼不收**、**他能做什麼**,
|
||||
// 而且不准出現狀態碼、模型名或 token 這種只有工程師看得懂的詞。
|
||||
func tooBigForWorkersAI(srcText, relPath string) string {
|
||||
if len(srcText) <= maxWorkersAIExtractBytes {
|
||||
return ""
|
||||
}
|
||||
// 中文一個字約 3 位元組——換算成「字數」才是使用者對得上的單位。
|
||||
wan := len(srcText) / 3 / 10000
|
||||
return fmt.Sprintf(
|
||||
"這份檔太大了(約 %d 萬字),雲端的整理模型一次讀不完,所以這次沒有收它。"+
|
||||
"把它拆成幾份小一點的檔就會自動收進來。", wan)
|
||||
}
|
||||
|
||||
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
|
||||
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
|
||||
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
|
||||
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, error) {
|
||||
func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string, origin SourceOrigin) ([]string, error) {
|
||||
if strings.TrimSpace(cypherURL) == "" {
|
||||
return nil, fmt.Errorf("workers-ai 萃取路需要 cypher_url(config)")
|
||||
}
|
||||
@@ -74,6 +115,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||||
}
|
||||
|
||||
// 🔴 明知送出去一定會失敗,就不要送(見 maxWorkersAIExtractBytes)。
|
||||
// 早一步擋下=不燒額度、不占佇列,而且使用者看到的是人話不是上游錯誤碼。
|
||||
if why := tooBigForWorkersAI(srcText, relPath); why != "" {
|
||||
return nil, fmt.Errorf("%s", why)
|
||||
}
|
||||
|
||||
pageName := pageNameOf(relPath)
|
||||
// #134:prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
|
||||
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
|
||||
@@ -137,7 +184,7 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, time.Now())
|
||||
cards, berr := BuildWikiDoc(absRoot, relPath, srcText, ex, origin, time.Now())
|
||||
if berr != nil {
|
||||
return nil, berr
|
||||
}
|
||||
@@ -155,6 +202,17 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
// vault 落隱藏目錄,且**檔名一律帶 arcrun- 前綴**(第二輪:光換目錄擋不住撞名,
|
||||
// 因為 Logseq 的頁名是 basename)。落地前先查目標存不存在、不無條件覆蓋(safeWriteCard)。
|
||||
cardRel := cardRelFor(absRoot, pageName)
|
||||
// 🔴 inkstone/Arcrun#180(2026-08-28):**不准把萃取結果寫回原稿自己身上。**
|
||||
// 舊制的落點是 `system-dev/wiki/cards/arcrun-<頁名>.md`,而 `MarkName` 是冪等的
|
||||
// ⇒ 原稿本來就住在那裡、名字本來就帶前綴時(leo 的 9 張現成卡正是這一種),
|
||||
// 算出來的目的地**就是原稿自己**。真寫下去=原稿被自己的摘要蓋掉(safeWriteCard
|
||||
// 會留一份 `.bak-*`,但下一輪內容又變了 ⇒ 每輪重萃、每輪燒一次額度)。
|
||||
// 這條路只在**舊雲端**(回 `card` 而不是 `output`)才走得到,所以平常撞不到;
|
||||
// #180 讓 `system-dev/` 收得到之後就撞得到了。誠實報錯,不要靜默略過。
|
||||
if cardRel == filepath.ToSlash(relPath) {
|
||||
return nil, fmt.Errorf("這份檔案本身就在卡片產物區、而且已經帶著我們的標記(%s)"+
|
||||
"——整理它會蓋掉它自己。請把它移到別的資料夾,或更新你的知識庫到新版", relPath)
|
||||
}
|
||||
dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
|
||||
if err := safeWriteCard(absRoot, dest, []byte(card)); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) {
|
||||
})
|
||||
defer closeFn()
|
||||
|
||||
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
|
||||
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) {
|
||||
})
|
||||
defer closeFn()
|
||||
|
||||
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel)
|
||||
cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel, testOrigin())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func TestExtractWithWorkersAI_SendsSharedPrompt(t *testing.T) {
|
||||
})
|
||||
defer closeFn()
|
||||
|
||||
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName); err != nil {
|
||||
if _, err := ExtractWithWorkersAI(url, "key123", root, srcName, testOrigin()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcText, err := ConvertToText(srcName, []byte(srcBody))
|
||||
@@ -181,7 +181,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
undo := gemmaCardStub(t, fixture)
|
||||
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName)
|
||||
cardsG, errG := ExtractWithGemma("k-test", "", rootG, srcName, testOrigin())
|
||||
undo()
|
||||
if errG != nil {
|
||||
t.Fatal(errG)
|
||||
@@ -195,7 +195,7 @@ func TestExtractWorkersAIAndGemmaProduceIdenticalWiki(t *testing.T) {
|
||||
url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": fixture})
|
||||
})
|
||||
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName)
|
||||
cardsW, errW := ExtractWithWorkersAI(url, "key123", rootW, srcName, testOrigin())
|
||||
closeFn()
|
||||
if errW != nil {
|
||||
t.Fatal(errW)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFolderProgressLandsInStatus 守住 `inkstone/arcrun-rag#159` 的接線:
|
||||
// **逐資料夾的同步現況真的會寫進 status.json**,而且是每個根各一份。
|
||||
//
|
||||
// 為什麼要有這一條:小幫手畫面上那個打勾完全靠這個欄位。它沒寫出來的話,
|
||||
// 前端拿不到資料 ⇒ 每一列都會落到 unknown ⇒ 使用者又回到「看不出同步了沒」。
|
||||
func TestFolderProgressLandsInStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
rootA := filepath.Join(base, "A")
|
||||
rootB := filepath.Join(base, "B")
|
||||
for _, d := range []string{rootA, rootB} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(rootA, "a1.md"), []byte("# A1"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootA, "a2.md"), []byte("# A2"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(rootB, "b1.md"), []byte("# B1"), 0o644)
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{rootA, rootB}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("讀不到 status.json:%v", err)
|
||||
}
|
||||
if len(st.FolderProgress) != 2 {
|
||||
t.Fatalf("folder_progress 應該每個看守資料夾各一份(2),got %d:%+v",
|
||||
len(st.FolderProgress), st.FolderProgress)
|
||||
}
|
||||
if got := st.FolderProgress[rootA].Total; got != 2 {
|
||||
t.Errorf("A 的分母應為 2,got %d", got)
|
||||
}
|
||||
if got := st.FolderProgress[rootB].Total; got != 1 {
|
||||
t.Errorf("B 的分母應為 1,got %d", got)
|
||||
}
|
||||
// 🔴 逐資料夾加起來要等於首頁那行總量——它們本來就是同一組數字被切開,
|
||||
// 對不起來就代表有人另算了一套(那正是這張票要拔掉的病)。
|
||||
var sum SyncProgress
|
||||
for _, p := range st.FolderProgress {
|
||||
sum = sum.Add(p)
|
||||
}
|
||||
if sum.Total != st.Progress.Total-st.Progress.Unreadable {
|
||||
t.Errorf("逐資料夾合計 %d ≠ 首頁總量 %d(扣掉沒進 manifest 的 %d)",
|
||||
sum.Total, st.Progress.Total, st.Progress.Unreadable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloudVersionSurvivesOneFailedProbe 守住票的追加條件 6:
|
||||
// **「連不上」的判準不能是「這一輪解析失敗」。**
|
||||
//
|
||||
// leo 2026-08-28 畫面上寫「目前連不上這個知識庫,查不到版本」,
|
||||
// 而同一台機器 curl 打同一支 /health 是 HTTP 200——那是機器當下 DNS 抽風。
|
||||
// 一次探測失敗不准抹掉我們已經知道的版本。
|
||||
func TestCloudVersionSurvivesOneFailedProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "R")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(root, "r.md"), []byte("# R"), 0o644)
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: filepath.Join(base, "manifest.json"),
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
|
||||
orig := fetchCloudVersion
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
// 「最新版是多少」也要釘住,否則這條測試會依賴 install.arcrun.dev 通不通
|
||||
// ——那樣測的就不是我們要測的東西了(而且 CI 離線就紅)。
|
||||
origLatest := fetchLatestCloudReleaseRaw
|
||||
fetchLatestCloudReleaseRaw = func() (string, bool) { return "1.4.58", true }
|
||||
latestMu.Lock()
|
||||
latestFetched = time.Time{} // 清掉節流快取,讓 stub 這一輪真的生效
|
||||
latestMu.Unlock()
|
||||
defer func() {
|
||||
fetchLatestCloudReleaseRaw = origLatest
|
||||
latestMu.Lock()
|
||||
latestFetched = time.Time{}
|
||||
latestMu.Unlock()
|
||||
}()
|
||||
|
||||
// 第一輪:查得到版本。
|
||||
fetchCloudVersion = func(string) (string, bool) { return "1.4.60", true }
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
// 第二輪:探測失敗(模擬 DNS 抽風)。
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.AccountDetails) != 1 {
|
||||
t.Fatalf("應該只有一個帳號,got %d", len(st.AccountDetails))
|
||||
}
|
||||
for host, acc := range st.AccountDetails {
|
||||
if acc.CloudVersion != "1.4.60" {
|
||||
t.Errorf("%s:一次探測失敗就把版本抹掉了(got %q,應沿用 1.4.60)", host, acc.CloudVersion)
|
||||
}
|
||||
if !acc.CloudUpdateKnown {
|
||||
t.Errorf("%s:版本明明還知道,卻標成「查不到」", host)
|
||||
}
|
||||
// 可達性是另一件事,要照實記——不准為了讓畫面好看就說連得上。
|
||||
if acc.CloudCheckOK {
|
||||
t.Errorf("%s:這一輪明明沒連上,cloud_check_ok 卻是 true", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -318,7 +318,7 @@ func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents,
|
||||
continue
|
||||
}
|
||||
pace()
|
||||
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
|
||||
status, _, err := cfg.postJSON(stepFolderCard, cfg.triggerURL(wf), map[string]any{
|
||||
"page_name": c.Page,
|
||||
"path": c.Path,
|
||||
"card_content": c.Content,
|
||||
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
// foldertree.go — 把「這個看守資料夾長什麼樣」做成**結構化的樹**送上雲端
|
||||
// (InkStoneCo#44 線 A,leo 2026-08-17 規格)。
|
||||
//
|
||||
// 🔴 leo 的原話就是這支檔的規格:
|
||||
//
|
||||
// 「昨天我看到的是掃到了指定資料夾,但 **tree 在 portal 上應該顯示該資料夾的 tree**,
|
||||
// 並顯示該資料夾**每個子資料夾的「同步文件數/總文件數」**,
|
||||
// 不上傳通常是不支援,比如程式碼、不支援的格式。」
|
||||
//
|
||||
// ⇒ 這個畫面同時要回答「哪些沒上傳、為什麼」。使用者不必問人、也不必看 log。
|
||||
//
|
||||
// ── 為什麼不是重用 inventory.go 的總覽卡 ─────────────────────────────────────
|
||||
// `inventory.go` 已經在送一張「資料夾總覽」的**知識卡**(markdown),但它服務的是
|
||||
// **檢索**(讓 rag-chat 查得到「這個資料夾有什麼」),而且:
|
||||
// - 它只數 manifest entries =**只數收得下的檔**,算不出「總文件數」的分母;
|
||||
// - 它只到第一層目錄,沒有樹;
|
||||
// - 它是一段 markdown ⇒ portal 沒辦法拿它畫出可摺疊、每節點兩個數字的樹。
|
||||
//
|
||||
// ⇒ 兩者消費者不同(一個給 LLM 檢索、一個給 portal 畫面),資料形狀也不同。
|
||||
// **但分子分母只有一個來源**:本檔的分母(總數/不支援/被排除)由 `Scan()` 走訪時
|
||||
// 順手數出來(同一趟走訪、同一套判準,見 scan.go 的 dirStat),分子(已同步)由
|
||||
// manifest 現況導出 ⇒ 不會出現「畫面說 A、檔案說 B」那種兩份實作漂移的病。
|
||||
//
|
||||
// ── 為什麼「空資料夾」是這支檔的一等公民 ────────────────────────────────────
|
||||
// `arcrun-rag#106`(leo 2026-08-15):
|
||||
//
|
||||
// 「如果指定同步的是**空資料夾**,雲端不出現,**這個不行**,因為
|
||||
// 我指定資料夾雲端和地端是 navigate 的功能,
|
||||
// **不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?**」
|
||||
//
|
||||
// 今天雲端的庫是「有卡才有庫」的副作用 ⇒ 空資料夾指定了什麼都不會出現。
|
||||
// 本檔**每一輪都送**(內容沒變才跳過),而且**沒有檔案時照送**——所以指定的當下
|
||||
// 那個資料夾就在雲端存在了,內容是 0。收端負責順手把庫登記起來(冪等)。
|
||||
//
|
||||
// 紅線對齊:本檔不寫任何本機檔案、不動使用者原稿;資料一律走既有的 portal daemon
|
||||
// HTTP 端點(與 `/portal/daemon/extract`、`/portal/daemon/libraries` 同一族,
|
||||
// 同一把 `X-Arcrun-API-Key`),不新開第二套上行機制。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxFolderTreeNodes:一棵樹最多送幾個節點。
|
||||
//
|
||||
// 為什麼要有上限:leo 的 `InkStoneCo` 一個資料夾就掃到 8,339 份檔,目錄數同量級。
|
||||
// 一次把幾千個節點推上去,既撞 Workers 的 subrequest 天花板,也會把 portal 畫成
|
||||
// 一面沒人看得完的牆。
|
||||
//
|
||||
// 🔴 **超過不准安靜地截掉**——`FolderTree.Truncated` 會被送上去、由畫面講出來
|
||||
//(同 #104「排除規則要看得見」那條紅線:安靜地少講與講一個 0,對使用者是同一件事)。
|
||||
//
|
||||
// 🔴 2026-08-28(inkstone/Arcrun#180)從 300 提到 2000。leo 的規格是
|
||||
// 「**雲端看到所有的 folder**」,而剪枝拿掉之後節點會變多——300 會讓「看得到全部」
|
||||
// 在大資料夾上靜默失效(`Truncated` 雖然誠實,但那是在說「我做不到你要的事」)。
|
||||
//
|
||||
// **實測(本次改動後,同一台機器)**:
|
||||
//
|
||||
// youlinhsieh-test1 12 個節點(地端 `find -type d` 非隱藏 12)
|
||||
// youlinhsieh-test2 13(地端 13)
|
||||
// pms 53(地端 360——差額是 node_modules/dist/.gitignore 宣告的,
|
||||
// 那幾種仍然剪枝,但節點與理由都在)
|
||||
// InkStoneCo 62(地端 14,923,同上)
|
||||
//
|
||||
// ⇒ 真實資料夾離 300 都還很遠;2000 是留給「一個沒有 .gitignore 的大筆記庫」的餘裕。
|
||||
// 一棵樹在收端是一把 KV、整棵覆寫,2000 個節點約 400 KB,離 KV 的 25 MB 很遠。
|
||||
MaxFolderTreeNodes = 2000
|
||||
|
||||
// folderTreeRetryDelay:整棵樹送失敗後,同一份內容多久才准再試。
|
||||
// 理由同 inventory.go:積壓時每輪都有事件,沒有退避就是每 5 秒撞一次(t195 教訓)。
|
||||
folderTreeRetryDelay = 10 * time.Minute
|
||||
|
||||
// folderTreeMinInterval:**成功送出**之後,同一個資料夾至少隔多久才准再送。
|
||||
//
|
||||
// 🔴 為什麼需要這道閘(而總覽卡不需要):總覽卡多了一條「本輪無事件就不送」,
|
||||
// 而這棵樹刻意沒有那一條(空資料夾從頭到尾沒有事件,見下面 syncFolderTree 的說明)。
|
||||
// 少了那道閘,初次同步時的行為是:每收完一個檔「已同步數」就變一次
|
||||
// ⇒ 內容雜湊每輪都不同 ⇒ **每 5 秒送一整棵樹**。
|
||||
// 收端一次回報=一次 KV 寫入,而 Workers KV 免費層是 1000 writes/day
|
||||
// ⇒ 沒有這道閘,一個資料夾的初次同步就能在一小時內把當天額度燒光,
|
||||
// 而額度燒光的症狀是「畫面停在某個數字不動」——看起來像壞掉,不像額度。
|
||||
//
|
||||
// 2 分鐘是「人盯著畫面等進度」與「一天的額度」之間的折衷:
|
||||
// 最壞 30 次/小時 × 24 = 720 次/天/資料夾,靜止時是 0(雜湊閘擋住)。
|
||||
// **首次送出不受此限**(m.FolderTreeHash == "")——指定資料夾的當下就要看得見它,
|
||||
// 那正是 arcrun-rag#106 的重點。
|
||||
folderTreeMinInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
// FolderNode=樹上的一個節點(=地端的一個資料夾)。
|
||||
//
|
||||
// 🔴 數字全部是**這一層直接放的檔案**(不含子資料夾)。子樹的合計由畫面自己疊——
|
||||
// 存兩套(直接數+累計數)就是同一件事兩份實作,遲早對不起來。
|
||||
type FolderNode struct {
|
||||
// Path=相對監看根的路徑,`""` 代表根自己。一律 `/` 分隔(跨平台一致)。
|
||||
Path string `json:"path"`
|
||||
// Name=這一層的名字(根用資料夾 basename,人看得懂「這是哪個資料夾」)。
|
||||
Name string `json:"name"`
|
||||
// Parent=上一層的 Path;根自己是 `"-"`(空字串已經被根佔走,要分得出「我是根」)。
|
||||
Parent string `json:"parent"`
|
||||
Depth int `json:"depth"`
|
||||
|
||||
// TotalFiles=這一層看得到的檔案總數(**分母**)——含不支援的、含被策略排除的。
|
||||
// 這就是使用者在 Finder 裡數得出來的那個數字(隱藏檔除外,他也看不到)。
|
||||
TotalFiles int `json:"total_files"`
|
||||
// SyncedFiles=已經送進雲端知識庫、而且送上去之後內容沒再變過的(**分子**)。
|
||||
SyncedFiles int `json:"synced_files"`
|
||||
// PendingFiles=認得、還沒送完(排隊中/退避中/已放棄自動重試)。
|
||||
PendingFiles int `json:"pending_files"`
|
||||
// UnsupportedFiles=副檔名我們還讀不了的(leo 講的「不支援的格式」)。
|
||||
UnsupportedFiles int `json:"unsupported_files"`
|
||||
// ExcludedFiles=收檔策略決定不收的(leo 講的「程式碼」多半落在這裡,見 ingestplan.go)。
|
||||
ExcludedFiles int `json:"excluded_files"`
|
||||
|
||||
// Skipped=**這一層的檔案這次一個都不收**。SkipReason 說得出為什麼。
|
||||
//
|
||||
// 🔴 2026-08-28(inkstone/Arcrun#180)語意收窄了一格,因為 leo 推翻了原本的做法:
|
||||
// 以前 Skipped 同時代表「整棵沒走進去」⇒ 數字全是 0 而且不是事實 ⇒ **底下一個
|
||||
// 子節點都不會生**。leo 的原話:「**所有的 system-dev 都可以展開,因為就算沒有
|
||||
// 可萃的它也有下層**」「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現」。
|
||||
// ⇒ 現在絕大多數 Skipped 節點是**走進去了、數字是真的、子節點也都在**,
|
||||
// 只是這一層的檔不收(範本檔、非本次收檔範圍…)。
|
||||
//
|
||||
// 仍然有數字是 0 而且不是事實的那一種——真的沒走進去的(`node_modules` 這類,
|
||||
// 見 IngestPlan.SkipsDirWhy 剩下的五條)。**分辨方法:那種節點沒有子節點。**
|
||||
// 畫面看到 Skipped 一律改講 SkipReason,不要只顯示分子分母。
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
// SkipReason=一句話講給使用者聽的「為什麼整個沒收」(原文來自 IngestPlan.SkipsDirWhy)。
|
||||
SkipReason string `json:"skip_reason,omitempty"`
|
||||
}
|
||||
|
||||
// FolderTree=一個監看根的整棵樹,加上「為什麼只收這些」的那句話。
|
||||
type FolderTree struct {
|
||||
Library string `json:"library"` // 歸庫鍵(cfg.libraryFor(absRoot),每根唯一)
|
||||
DisplayName string `json:"display_name"` // 資料夾 basename,給人看的
|
||||
Root string `json:"root"` // 地端絕對路徑(navigator:回得去地端)
|
||||
Mode string `json:"mode"` // 收檔策略(all/curated-wiki/docs-only)
|
||||
Reason string `json:"reason"` // 那句人話(IngestPlan.Reason)
|
||||
Nodes []FolderNode `json:"nodes"`
|
||||
Truncated bool `json:"truncated,omitempty"` // 節點超過上限,畫面要講出來
|
||||
TotalNodes int `json:"total_nodes"` // 截斷前的真實節點數
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
}
|
||||
|
||||
// BuildFolderTree 把「走訪時數出來的分母」與「manifest 現況的分子」合成一棵樹。
|
||||
//
|
||||
// 純函式、零 IO、輸出確定(同一份輸入永遠同一份輸出)——冪等判斷(內容雜湊)與測試
|
||||
// 都靠這一點,與 BuildInventoryCard 同一套慣例。
|
||||
//
|
||||
// dirs=Scan() 走訪時逐目錄數出來的分母(見 scan.go dirStat)。
|
||||
// entries=Scan() 之後的 manifest 現況(分子的唯一來源)。
|
||||
// excludedDirs=整棵被剪掉的目錄與理由(Scan() 記的那份,**未裁切的全量**)。
|
||||
func BuildFolderTree(absRoot, library string, dirs map[string]*dirStat, entries map[string]*ManifestEntry, excludedDirs []ExcludedDir, plan IngestPlan, now time.Time) FolderTree {
|
||||
nodes := map[string]*FolderNode{}
|
||||
|
||||
// 遞迴閉包(補祖先鏈要呼叫自己)⇒ 必須先宣告再賦值。
|
||||
var ensure func(rel string) *FolderNode
|
||||
ensure = func(rel string) *FolderNode {
|
||||
if n, ok := nodes[rel]; ok {
|
||||
return n
|
||||
}
|
||||
n := &FolderNode{Path: rel, Name: folderNodeName(absRoot, rel), Parent: folderNodeParent(rel), Depth: folderNodeDepth(rel)}
|
||||
nodes[rel] = n
|
||||
// 中間層可能沒被 dirStat 記到(例如只有被剪掉的子目錄),補齊祖先鏈——
|
||||
// 少一層,畫面就接不成一棵樹(會變成一堆浮著的節點)。
|
||||
if rel != "" {
|
||||
ensure(n.Parent)
|
||||
}
|
||||
return n
|
||||
}
|
||||
ensure("") // 根一定存在,就算資料夾是空的(arcrun-rag#106 的整個重點)
|
||||
|
||||
for rel, st := range dirs {
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
n := ensure(rel)
|
||||
n.TotalFiles = st.total
|
||||
n.UnsupportedFiles = st.unsupported
|
||||
n.ExcludedFiles = st.excluded
|
||||
// #180:這一層走進去了、數字是真的,但**一個檔都沒收** ⇒ 照樣要講得出為什麼。
|
||||
// 以前這種節點根本不存在(整棵被剪掉),現在存在了,就不能只給一個沒有解釋的
|
||||
// `0 / 6`——那對使用者跟「安靜地少收」是同一件事。
|
||||
// 🔴 子節點與真實數字都留著(leo 2026-08-28:「跳過的節點仍要展得開」)。
|
||||
if st.total > 0 && st.excluded == st.total && st.excludeWhy != "" {
|
||||
n.Skipped = true
|
||||
n.SkipReason = st.excludeWhy
|
||||
}
|
||||
}
|
||||
|
||||
// 分子:manifest 現況。已送達且送上去之後沒再改過=已同步,其餘=還在路上。
|
||||
// 判準與 progress.go 的 Done 完全一致(同一句 `IngestedHash == ContentHash`),
|
||||
// 不另立第二把尺。
|
||||
for rel, e := range entries {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
n := ensure(folderOfRel(rel))
|
||||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||||
n.SyncedFiles++
|
||||
} else {
|
||||
n.PendingFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// 整棵被剪掉的目錄:列出來、講理由,數字留 0 但標 Skipped
|
||||
//(沒走進去就是不知道裡面有幾個檔——寫 0 當事實就是我們在編數字)。
|
||||
for _, ed := range excludedDirs {
|
||||
n := ensure(ed.Path)
|
||||
n.Skipped = true
|
||||
n.SkipReason = ed.Reason
|
||||
}
|
||||
|
||||
out := make([]FolderNode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out = append(out, *n)
|
||||
}
|
||||
// 排序=畫面每輪穩定(map 迭代順序隨機)。根永遠第一,其餘按路徑字母序。
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if (out[i].Path == "") != (out[j].Path == "") {
|
||||
return out[i].Path == ""
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
|
||||
total := len(out)
|
||||
truncated := false
|
||||
if total > MaxFolderTreeNodes {
|
||||
out = out[:MaxFolderTreeNodes]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
return FolderTree{
|
||||
Library: library,
|
||||
DisplayName: folderNodeName(absRoot, ""),
|
||||
Root: absRoot,
|
||||
Mode: string(plan.Mode),
|
||||
Reason: plan.Reason,
|
||||
Nodes: out,
|
||||
Truncated: truncated,
|
||||
TotalNodes: total,
|
||||
GeneratedAt: now.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// folderOfRel 回傳某個檔案相對路徑所屬的目錄(根層檔案回 `""`)。
|
||||
func folderOfRel(rel string) string {
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
if i := strings.LastIndexByte(rel, '/'); i > 0 {
|
||||
return rel[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func folderNodeParent(rel string) string {
|
||||
if rel == "" {
|
||||
return "-" // 根:刻意不是空字串,不然分不出「我是根」與「我的父親是根」
|
||||
}
|
||||
return folderOfRel(rel)
|
||||
}
|
||||
|
||||
func folderNodeDepth(rel string) int {
|
||||
if rel == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(rel, "/") + 1
|
||||
}
|
||||
|
||||
// folderNodeName 回傳節點顯示名。根用監看資料夾自己的 basename——
|
||||
// 使用者認得的是「我那個資料夾叫什麼」,不是一個空字串。
|
||||
func folderNodeName(absRoot, rel string) string {
|
||||
if rel != "" {
|
||||
return path.Base(rel)
|
||||
}
|
||||
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
|
||||
if base == "" || base == "." || base == "/" {
|
||||
return "知識資料夾"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// Hash 回傳整棵樹的內容雜湊(不含 GeneratedAt——時間每輪都變,含進去就等於沒有冪等)。
|
||||
func (t FolderTree) Hash() string {
|
||||
c := t
|
||||
c.GeneratedAt = 0
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// syncFolderTree 每輪掃描後把樹送上雲端。回 nil=這輪不必送。
|
||||
//
|
||||
// 冪等/防撞四層(前三層同 inventory.go 的慣例,刻意不另立一套):
|
||||
//
|
||||
// ①整棵樹的內容雜湊記在 manifest.folder_tree_hash,沒變不送;
|
||||
// ②同一份內容剛失敗過 → 退避窗口內不重撞;
|
||||
// ③**成功送過之後有最小間隔**(folderTreeMinInterval,理由見該常數);
|
||||
// ④收端整棵覆寫(同一個 library 一把 KV),重送不堆副本、也不會留下半棵樹。
|
||||
//
|
||||
// 🔴 與 syncInventory 的差別:**沒有「本輪無事件就不送」那一條**。
|
||||
// 空資料夾從頭到尾都沒有事件(arcrun-rag#106 的情境本身),漏掉這一送,
|
||||
// 「指定了資料夾雲端就看得到」這件事永遠不會發生。
|
||||
// ——但正因為拿掉了那道閘,才必須補上第③層,否則初次同步會變成每 5 秒送一次。
|
||||
//
|
||||
// 🔴 **整棵樹一次送**(本檔早先的版本切成每批 20 個節點,已拿掉):
|
||||
// 分批的唯一理由是「收端每個節點一次 KBDB 寫入=一個 subrequest」,而收端
|
||||
// (`cypher-executor` 的 `/portal/daemon/folder-tree`)把樹存成一把 KV、整棵覆寫
|
||||
// ⇒ 一次回報只有一次寫入。分批在那個設計下只剩壞處:中途失敗留下半棵樹,
|
||||
// 而且要另一套 prune 邏輯去清「這次沒報上來的節點」。
|
||||
//
|
||||
// 失敗不擋同步主流程(不設 exit):樹是畫面層,它壞了檔案同步照常走;
|
||||
// 但誠實回報 failed,不假綠。
|
||||
func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderTree, dryRun bool, now time.Time) *DirectResult {
|
||||
h := tree.Hash()
|
||||
if h != "" && h == m.FolderTreeHash {
|
||||
return nil // ①內容沒變
|
||||
}
|
||||
if h != "" && h == m.FolderTreeFailHash && now.Unix() < m.FolderTreeNextRetry {
|
||||
return nil // ②同一份內容剛失敗過,退避窗口內不重撞
|
||||
}
|
||||
// ③首次送出不受最小間隔限制(指定資料夾的當下就要看得見它);之後才節流。
|
||||
if m.FolderTreeHash != "" && now.Unix() < m.FolderTreeNextSend {
|
||||
return nil
|
||||
}
|
||||
res := &DirectResult{Type: "folder_tree", Path: absRoot}
|
||||
if dryRun {
|
||||
res.Status = "planned"
|
||||
return res
|
||||
}
|
||||
|
||||
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
|
||||
// Nodes 至少有根一個(BuildFolderTree 的 ensure("")),空資料夾照送——
|
||||
// 收端據此把庫登記起來,這正是 arcrun-rag#106 的解。
|
||||
body := map[string]any{
|
||||
"library": tree.Library,
|
||||
"display_name": tree.DisplayName,
|
||||
"root": tree.Root,
|
||||
"mode": tree.Mode,
|
||||
"reason": tree.Reason,
|
||||
"truncated": tree.Truncated,
|
||||
"total_nodes": tree.TotalNodes,
|
||||
"generated_at": tree.GeneratedAt,
|
||||
"sync_token": h,
|
||||
"nodes": tree.Nodes,
|
||||
}
|
||||
status, _, err := cfg.postJSON(stepFolderTree, cfg.folderTreeURL(), body)
|
||||
res.HTTPStatus = status
|
||||
if err != nil {
|
||||
res.Status = "failed"
|
||||
// 🔴 **這句話會出現在使用者的畫面上**(status.json → 托盤),所以不准裸露
|
||||
// 錯誤碼/HTTP 狀態/上游 JSON 原文(同 direct_quota_test.go 那份禁字表:
|
||||
// 「4006」「502」「HTTP」「neurons」)。技術細節不必寫進這句——
|
||||
// `res.HTTPStatus` 已經把狀態碼結構化地留著了,要查的人查得到。
|
||||
//
|
||||
// 📌 這是本分支自己踩到的:原本寫成 `… + err.Error()`,於是額度耗盡那天
|
||||
// 使用者看到的會是 `HTTP 502:{"error":"4006: you have used up your daily
|
||||
// free allocation of 10,000 neurons"}`——技術上正確,人話上是零。
|
||||
// 分類走 inventory.go 早就在用的 `isQuotaExhausted`(InkStoneCo#43 立的那支),
|
||||
// 不另造第二套判斷。
|
||||
if isQuotaExhausted(err.Error()) {
|
||||
res.Error = "雲端今天的額度用完了,資料夾結構會稍後自動補送(不影響檔案同步)"
|
||||
} else {
|
||||
res.Error = "資料夾結構這次沒送上去,稍後會自動再試(不影響檔案同步)"
|
||||
}
|
||||
m.FolderTreeFailHash = h
|
||||
m.FolderTreeNextRetry = now.Add(folderTreeRetryDelay).Unix()
|
||||
return res
|
||||
}
|
||||
res.Status = "ingested"
|
||||
m.FolderTreeHash = h
|
||||
m.FolderTreeFailHash = ""
|
||||
m.FolderTreeNextRetry = 0
|
||||
m.FolderTreeNextSend = now.Add(folderTreeMinInterval).Unix()
|
||||
return res
|
||||
}
|
||||
|
||||
// ── 本機那一份:桌面小幫手要攤開的,是同一棵樹 ─────────────────────────────
|
||||
//
|
||||
// 🔴 為什麼要在本機也落地一份(`inkstone/InkStoneCo#44`,leo 2026-08-26):
|
||||
//
|
||||
// leo 的交付定義第一段是「**在 Portal 和桌面小幫手上**,任何一個連上的資料夾
|
||||
// 都攤得開它完整的巢狀子資料夾樹」。上面的 syncFolderTree 只完成了前半——
|
||||
// 樹送上雲端、由 portal 讀回來畫。**桌面小幫手不能走那條路**,三個理由:
|
||||
//
|
||||
// ① 它要在**離線**、雲端額度用完、或 syncFolderTree 正在退避窗口裡的時候
|
||||
// 照樣攤得開——那些正是使用者最想知道「到底同步到哪了」的時刻;
|
||||
// ② 樹是**本機算出來的**(分母來自這台電腦的 WalkDir),繞一趟雲端再拿回來,
|
||||
// 等於讓本機畫面依賴一條它根本不需要的網路;
|
||||
// ③ syncFolderTree 有內容雜湊閘與最小間隔,**它回 nil 的輪次遠多於送出的輪次**
|
||||
// ——本機畫面不該跟著那道為了省 KV 額度而設的閘一起沉默。
|
||||
//
|
||||
// 🔴 **不另算第二套**(本票紅線):這裡存下來的就是 BuildFolderTree 交出、
|
||||
// 也正要送上雲端的那個 `FolderTree`,一個欄位都不重組、一個數字都不重算。
|
||||
// 桌面與 portal 看到的若有一天不一樣,那只可能是「哪一邊比較舊」,
|
||||
// 不可能是「兩邊各自算」。
|
||||
|
||||
// FolderTreeStore=本機的樹快照(key=監看根的絕對路徑)。
|
||||
//
|
||||
// 為什麼是「一個檔裝全部」而不是每個根一個檔:桌面小幫手拿得到的是
|
||||
// config 裡那串資料夾路徑,用路徑當 key 直接查得到;每根一檔就得讓 App 去
|
||||
// 複製 manifestPathFor 那條 host+路徑的雜湊公式——**那才是第二份實作**。
|
||||
type FolderTreeStore struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Trees map[string]FolderTree `json:"trees"`
|
||||
}
|
||||
|
||||
// FolderTreeStorePath 回傳本機樹快照的路徑:與 manifest/status.json 同目錄。
|
||||
func FolderTreeStorePath(manifestPath string) string {
|
||||
return filepath.Join(filepath.Dir(manifestPath), "folder-trees.json")
|
||||
}
|
||||
|
||||
// LoadFolderTreeStore 讀回上一輪的快照;讀不到/解析失敗都回可用的零值
|
||||
// (呼叫端當「沒有上一輪」處理,與 LoadSyncStatus 同一套慣例)。
|
||||
func LoadFolderTreeStore(path string) (FolderTreeStore, error) {
|
||||
s := FolderTreeStore{Trees: map[string]FolderTree{}}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return FolderTreeStore{Trees: map[string]FolderTree{}}, err
|
||||
}
|
||||
if s.Trees == nil {
|
||||
s.Trees = map[string]FolderTree{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SaveFolderTreeStore 覆寫快照。
|
||||
func SaveFolderTreeStore(path string, s FolderTreeStore) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// MergeFolderTreeStore 把「這一輪算出來的樹」併進上一輪的快照。
|
||||
//
|
||||
// 三條規則,每一條都對應一個會讓畫面說謊的情境:
|
||||
//
|
||||
// ① 這一輪算出來的**覆蓋**舊的——樹永遠是現況,不累積歷史。
|
||||
// ② 這一輪**沒算出來**(那個根掃描失敗、或還沒輪到)⇒ 沿用上一輪的。
|
||||
// 不然使用者會看到自己昨天還好好的資料夾突然變成「還沒回報」——
|
||||
// 那是 2026-08-05「明明做完了畫面卻說等待中」同一個形狀的病。
|
||||
// ③ 已經**不在看守清單**上的根⇒刪掉。留著就是一棵指向不存在設定的幽靈樹,
|
||||
// 而且使用者移除資料夾之後還看得到它,會以為沒移掉。
|
||||
//
|
||||
// knownRoots=這一輪 RunDirectOnce 打算處理的所有監看根(不論成功與否)。
|
||||
func MergeFolderTreeStore(prev FolderTreeStore, fresh map[string]FolderTree, knownRoots []string, now time.Time) FolderTreeStore {
|
||||
known := make(map[string]bool, len(knownRoots))
|
||||
for _, r := range knownRoots {
|
||||
known[r] = true
|
||||
}
|
||||
out := FolderTreeStore{UpdatedAt: now.UTC().Format(time.RFC3339), Trees: map[string]FolderTree{}}
|
||||
for root, t := range prev.Trees {
|
||||
if known[root] { // ②③:還在看守清單上才留,其餘丟掉
|
||||
out.Trees[root] = t
|
||||
}
|
||||
}
|
||||
for root, t := range fresh {
|
||||
out.Trees[root] = t // ①
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PublishFolderTreeNow 把**這一個根**剛算好的樹立刻寫進本機快照,不等整輪跑完。
|
||||
//
|
||||
// 🔴 為什麼要有它(`inkstone/arcrun-rag#153` 第二輪,2026-08-28 實測):
|
||||
// 一輪同步是**一條線**走完的,而對一個健康的雲端實例,每一發呼叫實測要 24〜33 秒
|
||||
// (`ARCRUN_TRACE=1` 量的:資料夾總覽 26.3s、目錄索引 24.0/24.1/23.5s、
|
||||
// 送出一份筆記 33.2/44.6s)。一個 12 層、10 個檔的資料夾,一輪就是**十幾分鐘**;
|
||||
// leo 真正的設定是三個帳號、好幾千個檔,一輪是**好幾小時**。
|
||||
//
|
||||
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地
|
||||
// ⇒ 使用者看到的是「小幫手開著、沒有錯誤、什麼都不動,資料夾結構永遠停在上一版」。
|
||||
// **那不是卡住,是這一輪還沒輪到寫它。**
|
||||
//
|
||||
// 樹本身是**純本機、秒級**算出來的(BuildFolderTree 在掃描一結束就有答案),
|
||||
// 它沒有任何理由要排在十幾分鐘的雲端佇列後面等。
|
||||
//
|
||||
// 與收工時那次 SaveFolderTreeStore 的分工:
|
||||
// - 這裡:只**更新這一個根**,不刪任何東西(還不知道整輪看守哪些根)。
|
||||
// - 收工:跑完整的 MergeFolderTreeStore,該刪的(已不看守的根)在那時才刪。
|
||||
// 兩次都是冪等的覆蓋,先寫一次不會讓收工那次結果不同。
|
||||
//
|
||||
// 失敗一律吞掉:快照是給畫面看的,寫不進去不該擋住同步本體。
|
||||
func PublishFolderTreeNow(manifestPath, root string, tree FolderTree, now time.Time) {
|
||||
if manifestPath == "" || root == "" || len(tree.Nodes) == 0 {
|
||||
return // 沒算出東西就別覆蓋上一輪的好資料(同 MergeFolderTreeStore 規則②)
|
||||
}
|
||||
path := FolderTreeStorePath(manifestPath)
|
||||
store, _ := LoadFolderTreeStore(path) // 讀不到=沒有上一輪,零值可用
|
||||
if store.Trees == nil {
|
||||
store.Trees = map[string]FolderTree{}
|
||||
}
|
||||
store.Trees[root] = tree
|
||||
store.UpdatedAt = now.UTC().Format(time.RFC3339)
|
||||
_ = SaveFolderTreeStore(path, store)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package collector
|
||||
|
||||
// foldertree_live_test.go — 對**真實的監看資料夾**跑一次樹,用 leo 給的機械判準對帳
|
||||
//(`inkstone/Arcrun#180`,leo 2026-08-28:
|
||||
// 「就在桌面,你為什麼沒看?最簡單的方式就像 terminal tree 一樣把被同步資料夾下的
|
||||
// 所有 folder 都遍歷後印出來」)。
|
||||
//
|
||||
// 🔴 為什麼 fixture 不夠(同 sourcerepair_live_test.go 的理由):
|
||||
// `foldertree_traversal_test.go` 用的是我們自己擺出來的形狀——它證明得了邏輯,
|
||||
// 證明不了「**leo 桌面上那個資料夾**現在到底展不展得開」。
|
||||
// 這張票的現象是他螢幕上的數字,對帳就要拿他螢幕上那個資料夾來對。
|
||||
//
|
||||
// 只讀磁碟、不碰網路、不寫 manifest,所以沒有「打錯實例」的風險;
|
||||
// 仍然預設 skip,因為路徑是機器專屬的。
|
||||
//
|
||||
// LIVE_TREE_ROOTS="/Users/x/Desktop/youlinhsieh-test1,/Users/x/Desktop/youlinhsieh-test2" \
|
||||
// go test -run TestLiveFolderTreeMatchesDisk -v ./
|
||||
//
|
||||
// 判準(leo 原話):**`find <root> -type d` 數出幾個目錄,雲端的樹就該有幾個節點**
|
||||
//(收不收是另一回事,`skip_reason` 照標)。
|
||||
// 例外只有一種且必須說得出口:**真的沒走進去**的那幾類(gitignore/工具產物/
|
||||
// 建置目錄/worktree/巢狀 repo)——它們仍然佔一個節點,但底下不生子節點,
|
||||
// 所以本測試把它們的子樹從地端這一側也扣掉,兩邊才是同一個問題的兩個答案。
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// diskDirsExcludingPrunedSubtrees=地端目錄清單,但「真的沒走進去」的那幾類只算它自己。
|
||||
func diskDirsExcludingPrunedSubtrees(t *testing.T, root string, plan IngestPlan) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return err //nolint:nilerr // 讀不到的目錄交給 WalkDir 自己回報
|
||||
}
|
||||
if p == root {
|
||||
out = append(out, "")
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
out = append(out, rel)
|
||||
if skip, _ := plan.SkipsDirWhy(rel, p); skip {
|
||||
return filepath.SkipDir // 節點留著(上一行已經 append),子樹不算
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLiveFolderTreeMatchesDisk(t *testing.T) {
|
||||
raw := strings.TrimSpace(os.Getenv("LIVE_TREE_ROOTS"))
|
||||
if raw == "" {
|
||||
t.Skip("設 LIVE_TREE_ROOTS=<資料夾,資料夾> 才跑(路徑是機器專屬的)")
|
||||
}
|
||||
for _, root := range strings.Split(raw, ",") {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
t.Run(filepath.Base(root), func(t *testing.T) {
|
||||
plan := PlanIngest(root)
|
||||
m := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||
p, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := BuildFolderTree(root, "live", p.DirStats, m.Entries, p.AllExcludedDirs, plan, time.Now())
|
||||
|
||||
onTree := map[string]FolderNode{}
|
||||
for _, n := range tree.Nodes {
|
||||
onTree[n.Path] = n
|
||||
}
|
||||
want := diskDirsExcludingPrunedSubtrees(t, root, plan)
|
||||
|
||||
t.Logf("收檔策略:%s — %s", plan.Mode, plan.Reason)
|
||||
t.Logf("地端目錄 %d(扣掉沒走進去的子樹)/樹上節點 %d/truncated=%v",
|
||||
len(want), tree.TotalNodes, tree.Truncated)
|
||||
for _, n := range tree.Nodes {
|
||||
t.Logf(" %-46s tot=%-4d sync=%-4d excl=%-4d skipped=%v %s",
|
||||
"'"+n.Path+"'", n.TotalFiles, n.SyncedFiles, n.ExcludedFiles, n.Skipped, n.SkipReason)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, w := range want {
|
||||
if _, ok := onTree[w]; !ok {
|
||||
missing = append(missing, w)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("地端有、樹上沒有的資料夾(「不收」又被實作成「不走進去」了):%v", missing)
|
||||
}
|
||||
if tree.TotalNodes != len(want) {
|
||||
t.Fatalf("樹上節點 %d ≠ 地端資料夾 %d", tree.TotalNodes, len(want))
|
||||
}
|
||||
if tree.Truncated {
|
||||
t.Fatalf("被截斷了——MaxFolderTreeNodes=%d 不夠這個資料夾用,不准靜默截斷",
|
||||
MaxFolderTreeNodes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// foldertree_local_test.go — 本機那份樹快照(`inkstone/InkStoneCo#44` 桌面小幫手那半)
|
||||
//
|
||||
// 這裡測的不是「樹算得對不對」(那在 foldertree_test.go,已驗過、沒動),
|
||||
// 而是**落地那一層的三個會讓畫面說謊的情境**:
|
||||
//
|
||||
// ① 這一輪掃壞了 ⇒ 不准把上一輪的好資料蓋成空的(畫面不該突然說「還沒回報」)
|
||||
// ② 使用者移除了資料夾 ⇒ 快照裡那棵要消失(不然他以為沒移掉)
|
||||
// ③ 存進去、讀回來,要是同一棵樹(桌面與 portal 看到的是同一份資料)
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sampleTree(root string, synced, total int) FolderTree {
|
||||
return FolderTree{
|
||||
Library: "lib-" + filepath.Base(root),
|
||||
DisplayName: filepath.Base(root),
|
||||
Root: root,
|
||||
Mode: "all",
|
||||
Reason: "整個資料夾都收",
|
||||
Nodes: []FolderNode{
|
||||
{Path: "", Name: filepath.Base(root), Parent: "-", Depth: 0,
|
||||
TotalFiles: total, SyncedFiles: synced, UnsupportedFiles: total - synced},
|
||||
{Path: "sub", Name: "sub", Parent: "", Depth: 1, TotalFiles: 2, SyncedFiles: 2},
|
||||
},
|
||||
TotalNodes: 2,
|
||||
GeneratedAt: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 存-讀來回:一個欄位都不准掉。
|
||||
// 🔴 這條的重點不是 JSON 會不會動,是**桌面看到的必須是送上雲端的那一棵**——
|
||||
// 少一個欄位就等於本機悄悄長出了第二套資料形狀。
|
||||
func TestFolderTreeStoreRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := FolderTreeStorePath(filepath.Join(dir, "manifest.json"))
|
||||
if want := filepath.Join(dir, "folder-trees.json"); path != want {
|
||||
t.Fatalf("快照該與 manifest 同目錄:got %s want %s", path, want)
|
||||
}
|
||||
in := FolderTreeStore{
|
||||
UpdatedAt: "2026-08-26T00:00:00Z",
|
||||
Trees: map[string]FolderTree{"/a/b": sampleTree("/a/b", 3, 5)},
|
||||
}
|
||||
if err := SaveFolderTreeStore(path, in); err != nil {
|
||||
t.Fatalf("存檔失敗:%v", err)
|
||||
}
|
||||
out, err := LoadFolderTreeStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("讀回失敗:%v", err)
|
||||
}
|
||||
got, ok := out.Trees["/a/b"]
|
||||
if !ok {
|
||||
t.Fatalf("讀回來少了那棵樹:%+v", out)
|
||||
}
|
||||
if got.Library != "lib-b" || got.Reason != "整個資料夾都收" || got.Mode != "all" {
|
||||
t.Fatalf("欄位掉了:%+v", got)
|
||||
}
|
||||
if len(got.Nodes) != 2 || got.Nodes[0].TotalFiles != 5 || got.Nodes[0].SyncedFiles != 3 ||
|
||||
got.Nodes[0].Parent != "-" || got.Nodes[1].Depth != 1 {
|
||||
t.Fatalf("節點對不上:%+v", got.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// 讀不到/壞掉的檔一律回可用的零值——桌面小幫手不該因為快照壞掉就整個畫不出來。
|
||||
func TestFolderTreeStoreLoadMissingOrBroken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := LoadFolderTreeStore(filepath.Join(dir, "nope.json"))
|
||||
if err == nil {
|
||||
t.Fatalf("檔案不存在該回 error")
|
||||
}
|
||||
if s.Trees == nil {
|
||||
t.Fatalf("零值也要能直接用(Trees 不可為 nil)")
|
||||
}
|
||||
bad := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(bad, []byte("{ not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := LoadFolderTreeStore(bad)
|
||||
if err == nil {
|
||||
t.Fatalf("壞檔該回 error")
|
||||
}
|
||||
if s2.Trees == nil {
|
||||
t.Fatalf("壞檔也要回可用的零值")
|
||||
}
|
||||
}
|
||||
|
||||
// ①:這一輪某個根沒算出樹(掃描失敗/還沒輪到)⇒ 沿用上一輪的,不清空。
|
||||
func TestMergeFolderTreeStoreKeepsPreviousWhenRoundProducedNothing(t *testing.T) {
|
||||
now := time.Unix(2000, 0)
|
||||
prev := FolderTreeStore{Trees: map[string]FolderTree{
|
||||
"/a": sampleTree("/a", 3, 5),
|
||||
"/b": sampleTree("/b", 1, 1),
|
||||
}}
|
||||
// 這一輪只有 /a 算出來,/b 掃失敗(fresh 裡沒有它),但兩個都還在看守清單上。
|
||||
out := MergeFolderTreeStore(prev, map[string]FolderTree{"/a": sampleTree("/a", 5, 5)}, []string{"/a", "/b"}, now)
|
||||
if len(out.Trees) != 2 {
|
||||
t.Fatalf("兩個根都該在:%+v", out.Trees)
|
||||
}
|
||||
if out.Trees["/a"].Nodes[0].SyncedFiles != 5 {
|
||||
t.Fatalf("這一輪算出來的該覆蓋舊的:%+v", out.Trees["/a"].Nodes[0])
|
||||
}
|
||||
if out.Trees["/b"].Nodes[0].SyncedFiles != 1 {
|
||||
t.Fatalf("這一輪沒算出來的該沿用上一輪,不是被清掉:%+v", out.Trees["/b"])
|
||||
}
|
||||
if out.UpdatedAt == "" {
|
||||
t.Fatalf("要記得更新時間,畫面才說得出「這是什麼時候的現況」")
|
||||
}
|
||||
}
|
||||
|
||||
// ②:使用者把資料夾從看守清單移掉 ⇒ 快照裡那棵要消失。
|
||||
// 留著的話他移除完還看得到那棵樹,會以為移除沒作用(arcrun-rag#46 修過的同一個病)。
|
||||
func TestMergeFolderTreeStoreDropsUnwatchedRoots(t *testing.T) {
|
||||
now := time.Unix(3000, 0)
|
||||
prev := FolderTreeStore{Trees: map[string]FolderTree{
|
||||
"/a": sampleTree("/a", 3, 5),
|
||||
"/gone": sampleTree("/gone", 1, 1),
|
||||
}}
|
||||
out := MergeFolderTreeStore(prev, map[string]FolderTree{}, []string{"/a"}, now)
|
||||
if _, still := out.Trees["/gone"]; still {
|
||||
t.Fatalf("已經不看守的根不該留在快照裡:%+v", out.Trees)
|
||||
}
|
||||
if _, ok := out.Trees["/a"]; !ok {
|
||||
t.Fatalf("還在看守的根不該被一起刪掉:%+v", out.Trees)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 接線測試:跑完一輪,本機真的多出那份快照 ────────────────────────────────
|
||||
//
|
||||
// 🔴 這支存在的理由就是 `arcrun-rag#104` 那條教訓的翻版:
|
||||
// 「東西做好了,但**不在會被執行的那條路上**」。
|
||||
// 樹早就算得出來(BuildFolderTree 有測、送雲端有測),但只要 RunDirectOnce
|
||||
// 沒把它落地,桌面小幫手就永遠一個字都看不到——而單看那些既有的測試,全綠。
|
||||
func TestRunDirectOnce把樹落地給桌面小幫手(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "我的知識庫")
|
||||
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
|
||||
|
||||
orig := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
|
||||
defer func() { fetchCloudVersion = orig }()
|
||||
|
||||
manifest := filepath.Join(base, "manifest.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifest,
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
|
||||
MaxRemoved: DefaultMaxRemovedRatio,
|
||||
ExtractorExplicit: true, // 隔離變因:本測驗的是落地,不是萃取
|
||||
}
|
||||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
|
||||
t.Fatalf("同步應成功,exit=%d", exit)
|
||||
}
|
||||
|
||||
store, err := LoadFolderTreeStore(FolderTreeStorePath(manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("跑完一輪之後應該有快照可讀:%v", err)
|
||||
}
|
||||
tree, ok := store.Trees[root]
|
||||
if !ok {
|
||||
t.Fatalf("快照裡沒有這個看守根:%+v", store.Trees)
|
||||
}
|
||||
// 桌面畫面要的東西一件都不能少:根節點、子資料夾、分母、以及「為什麼沒收」的分類。
|
||||
if len(tree.Nodes) < 2 {
|
||||
t.Fatalf("樹太扁,畫不出巢狀結構:%+v", tree.Nodes)
|
||||
}
|
||||
if tree.DisplayName != "我的知識庫" {
|
||||
t.Errorf("根的顯示名要是使用者認得的資料夾名,got %q", tree.DisplayName)
|
||||
}
|
||||
var docs *FolderNode
|
||||
for i := range tree.Nodes {
|
||||
if tree.Nodes[i].Path == "docs" {
|
||||
docs = &tree.Nodes[i]
|
||||
}
|
||||
}
|
||||
if docs == nil {
|
||||
t.Fatalf("子資料夾 docs 不在樹上:%+v", tree.Nodes)
|
||||
}
|
||||
if docs.TotalFiles != 2 {
|
||||
t.Errorf("docs 分母=%d,應為 2(使用者在 Finder 裡看得到兩個檔):%+v", docs.TotalFiles, docs)
|
||||
}
|
||||
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
|
||||
t.Errorf("main.go 沒被分類到「為什麼沒上去」:%+v", docs)
|
||||
}
|
||||
|
||||
// 🔴 不准併進 status.json:那支每秒被讀一次,樹上限 300 個節點。
|
||||
raw, err := os.ReadFile(StatusFilePath(manifest))
|
||||
if err != nil {
|
||||
t.Fatalf("status.json 該存在:%v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "folder_tree") || strings.Contains(string(raw), "\"nodes\"") {
|
||||
t.Errorf("樹跑進 status.json 了——那支每秒讀一次,不該扛這個")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// foldertree_publish_test.go — 「畫面有沒有跟上」的網(`inkstone/arcrun-rag#153` 第二輪)。
|
||||
//
|
||||
// 🔴 這裡測的判準跟 stallguard_test.go **刻意不一樣**,因為第一輪測錯了東西:
|
||||
// 那一輪的網問的是「**有沒有發出警告**」,而總管 2026-08-28 端到端實測打回來的正是
|
||||
// 「閘在跑、警告會發,**但主流程仍然沒走下去**」。
|
||||
//
|
||||
// stallguard_test.go 端點**不回應**時 → 這一輪要跳過它、要有話說
|
||||
// foldertree_publish_test 端點**回應但很慢**時 → 畫面要的東西**不准排在後面等**
|
||||
//
|
||||
// 為什麼第二種才是使用者真正撞到的(`ARCRUN_TRACE=1` 實測,健康的 youlin 實例):
|
||||
// 每一發雲端呼叫 24〜33 秒,**沒有一發接近 300 秒** ⇒ 逾時不會觸發、斷路器不會跳、
|
||||
// 播報也大多不會響(門檻 30 秒)。一輪就這樣安靜地跑十幾分鐘到幾小時,
|
||||
// 而 `folder-trees.json` 從前**只在整輪的最後**才落地。
|
||||
// 使用者看到的是「小幫手開著、沒有錯誤、資料夾結構永遠停在上一版」。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// slowButHealthyServer=**會回應、只是慢**。這是本檔的主角,不是黑洞。
|
||||
func slowButHealthyServer(t *testing.T, delay time.Duration, hits *int64) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(hits, 1)
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// 🔴 本票第二輪的核心:**雲端每一發都很慢(但都會回來)時,資料夾結構仍然要在
|
||||
// 這一輪還在跑的時候就已經寫好**。
|
||||
//
|
||||
// 這條會抓到的回歸:把樹的落地搬回「整輪最後」——那時它會等到所有雲端呼叫跑完,
|
||||
// 使用者的畫面就又回到「永遠停在上一版」。
|
||||
func TestFolderTreeLandsWhileTheRoundIsStillRunning(t *testing.T) {
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
// 每一發 400ms——遠低於任何逾時(所以斷路器不會跳,正是實測的形狀),
|
||||
// 但夠多發就足以把一輪拖長。
|
||||
var hits int64
|
||||
slow := slowButHealthyServer(t, 400*time.Millisecond, &hits)
|
||||
|
||||
// 多層資料夾+多個檔:讓「雲端呼叫的總時間」明顯大於「算一棵樹的時間」。
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"a", "b", "c", "d", "e", "a/a1", "b/b1"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "top.md"), []byte("# top\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
treePath := FolderTreeStorePath(manifestPath)
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
|
||||
roundDone := make(chan time.Duration, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
RunDirectOnce(cfg, false)
|
||||
roundDone <- time.Since(start)
|
||||
}()
|
||||
|
||||
// 一邊等這一輪跑,一邊盯著快照什麼時候出現。
|
||||
var treeAt time.Duration
|
||||
var roundAt time.Duration
|
||||
poll := time.NewTicker(20 * time.Millisecond)
|
||||
defer poll.Stop()
|
||||
deadline := time.After(60 * time.Second)
|
||||
watch:
|
||||
for {
|
||||
select {
|
||||
case roundAt = <-roundDone:
|
||||
break watch
|
||||
case <-deadline:
|
||||
t.Fatalf("🔴 60 秒內這一輪還沒跑完(雲端每一發只有 400ms,這不該發生)")
|
||||
case <-poll.C:
|
||||
if treeAt == 0 {
|
||||
if _, err := os.Stat(treePath); err == nil {
|
||||
treeAt = time.Since(start)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if treeAt == 0 {
|
||||
t.Fatalf("🔴 整輪跑完(%v)之前,folder-trees.json 一次都沒出現過——"+
|
||||
"畫面上的資料夾結構會一直停在上一版", roundAt)
|
||||
}
|
||||
t.Logf("樹落地於 %v/整輪跑完於 %v/雲端被打了 %d 次", treeAt, roundAt, atomic.LoadInt64(&hits))
|
||||
|
||||
// 🔴 判準:樹要在**這一輪還早**的時候就落地,不是跟著整輪一起結束。
|
||||
// 用比例而不是絕對秒數——CI 機器快慢不同,但「排在雲端佇列前面」這個性質不變。
|
||||
if treeAt > roundAt/2 {
|
||||
t.Fatalf("🔴 樹落地於 %v,而整輪 %v——它還是排在雲端呼叫後面等。\n"+
|
||||
"本票第二輪的病就是這個:不是卡住,是這一輪還沒輪到寫它。", treeAt, roundAt)
|
||||
}
|
||||
|
||||
// 內容也要對:使用者看到的節點數要等於地端真的有幾個資料夾。
|
||||
store, err := LoadFolderTreeStore(treePath)
|
||||
if err != nil {
|
||||
t.Fatalf("讀不回快照:%v", err)
|
||||
}
|
||||
tree, ok := store.Trees[root]
|
||||
if !ok {
|
||||
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
|
||||
}
|
||||
// 根 + a b c d e a/a1 b/b1 = 8
|
||||
if tree.TotalNodes != 8 || len(tree.Nodes) != 8 {
|
||||
t.Fatalf("🔴 節點數不對:TotalNodes=%d len(Nodes)=%d,want 8(根+7 個子資料夾)",
|
||||
tree.TotalNodes, len(tree.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// 收工那一次的合併語意不准被「提早落地」破壞:
|
||||
// 已經不看守的根,仍然要在整輪結束時被清掉(不然使用者移除了資料夾還看得到它)。
|
||||
func TestEarlyPublishStillLetsTheFinalMergeDropUnwatchedRoots(t *testing.T) {
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
var hits int64
|
||||
srv := slowButHealthyServer(t, time.Millisecond, &hits)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
treePath := FolderTreeStorePath(manifestPath)
|
||||
|
||||
// 先塞一棵「已經不看守」的舊樹進快照。
|
||||
if err := SaveFolderTreeStore(treePath, FolderTreeStore{
|
||||
UpdatedAt: "2026-08-01T00:00:00Z",
|
||||
Trees: map[string]FolderTree{"/已經移除的資料夾": sampleTree("/已經移除的資料夾", 1, 1)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
store, err := LoadFolderTreeStore(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := store.Trees["/已經移除的資料夾"]; still {
|
||||
t.Fatalf("🔴 已經不看守的根還留在快照裡——使用者移除完還看得到它:%+v", store.Trees)
|
||||
}
|
||||
if _, ok := store.Trees[root]; !ok {
|
||||
t.Fatalf("正在看守的那棵反而不見了:%+v", store.Trees)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// foldertree_test.go — 資料夾樹(InkStoneCo#44,leo 2026-08-17)
|
||||
//
|
||||
// leo 的驗收情境就是這裡測的東西:
|
||||
//
|
||||
// 「指定一個**有子資料夾、且混著支援與不支援檔案**的資料夾 → portal 出現那棵樹,
|
||||
// 每個節點兩個數字對得上實際檔案數」
|
||||
// 「指定一個**空資料夾** → 它也要出現」
|
||||
//
|
||||
// 🔴 本檔測的是「分子分母對不對得上實際檔案」——那正是這條規格的重點:
|
||||
// **兩個數字不相等是正常的,但差額必須解釋得了**。所以每個案例都驗不變式
|
||||
//
|
||||
// total == synced + pending + unsupported + excluded
|
||||
//
|
||||
// 而不是只驗「有送出去」。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// answeredFolderTreePost 讓「假 cypher」認得資料夾樹這個端點:是它就自己回 200 並回報 true,
|
||||
// 呼叫端 `return` 即可,不要把它算進卡片的統計裡。
|
||||
//
|
||||
// 🔴 這支存在的理由是一個實撞(2026-08-17,本分支自己踩的):本套件的假 cypher 幾乎都是
|
||||
// **萬用 handler**——「收到什麼都當成一次卡片 POST」,然後 `m["page_name"].(string)`。
|
||||
// #44 之後多了第三個端點(`/portal/daemon/folder-tree`),它的 body **沒有 page_name**
|
||||
// ⇒ 那些 handler 拿 nil 做型別斷言而 panic。而 panic 發生在 handler 的 goroutine 裡
|
||||
// ⇒ 測試不是紅,是**掛住到 8 分鐘 timeout**(比紅更難查:看起來像「測試很慢」)。
|
||||
//
|
||||
// ⇒ 以後再加第四個 daemon 端點時,凡是「數 POST 幾次/記 page_name」的 stub
|
||||
// 都要先問過這一句,不要再讓萬用 handler 去猜。
|
||||
func answeredFolderTreePost(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
return false
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return true
|
||||
}
|
||||
|
||||
// nodeOf 從一棵樹裡撈出某個路徑的節點(找不到就讓測試自己 Fatal,不回 nil 讓後面 panic)。
|
||||
func treeNodeOf(t *testing.T, tree FolderTree, path string) FolderNode {
|
||||
t.Helper()
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Path == path {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("樹裡沒有節點 %q,實際有:%v", path, nodePaths(tree))
|
||||
return FolderNode{}
|
||||
}
|
||||
|
||||
func nodePaths(tree FolderTree) []string {
|
||||
out := make([]string, 0, len(tree.Nodes))
|
||||
for _, n := range tree.Nodes {
|
||||
out = append(out, n.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 掃一個真實的暫存資料夾,把 Scan() 數出來的分母與 manifest 現況合成樹。
|
||||
// 刻意走真的 Scan()——分母的判準活在那趟 WalkDir 裡,繞過它就等於測了另一套實作。
|
||||
func buildTreeFromDisk(t *testing.T, root string) FolderTree {
|
||||
t.Helper()
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
plan := PlanIngest(root)
|
||||
payload, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan 失敗:%v", err)
|
||||
}
|
||||
return BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1786900000, 0))
|
||||
}
|
||||
|
||||
// ── leo 的驗收情境①:有子資料夾、混著支援與不支援的檔案 ────────────────────────
|
||||
|
||||
func TestFolderTree混合資料夾的兩個數字對得上(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
|
||||
mustWrite(t, filepath.Join(root, "docs", "簡報.pptx"), "binary-ish")
|
||||
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
|
||||
mustWrite(t, filepath.Join(root, "img", "logo.png"), "PNG")
|
||||
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
|
||||
// 樹要長得出來(根+docs+img),而且根一定在第一個(畫面靠這個順序)
|
||||
if len(tree.Nodes) < 3 {
|
||||
t.Fatalf("節點太少:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.Nodes[0].Path != "" {
|
||||
t.Fatalf("根不在第一個:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.DisplayName == "" {
|
||||
t.Error("根沒有顯示名——使用者認得的是資料夾名字,不是空字串")
|
||||
}
|
||||
|
||||
// 🔴 不變式:每一層的分母必須解釋得完(差額=不支援+不收+還在路上)
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Skipped {
|
||||
continue // 沒走進去 ⇒ 數字全 0 且不是事實,見下一支測試
|
||||
}
|
||||
sum := n.SyncedFiles + n.PendingFiles + n.UnsupportedFiles + n.ExcludedFiles
|
||||
if n.TotalFiles != sum {
|
||||
t.Errorf("節點 %q 的差額解釋不了:total=%d 但 synced+pending+unsupported+excluded=%d(%+v)",
|
||||
n.Path, n.TotalFiles, sum, n)
|
||||
}
|
||||
}
|
||||
|
||||
// docs 這一層:Finder 裡看得到 3 個檔 ⇒ 分母就是 3(不是「我們收得下的那 1 個」)。
|
||||
// 這是整條規格的核心:分母是使用者數得出來的那個數字。
|
||||
docs := treeNodeOf(t, tree, "docs")
|
||||
if docs.TotalFiles != 3 {
|
||||
t.Errorf("docs 分母=%d,應為 3(.md/.pptx/.go 使用者都看得到):%+v", docs.TotalFiles, docs)
|
||||
}
|
||||
// 且「沒上傳的那些」要分得出是哪一類——leo:「不上傳通常是不支援,比如程式碼、不支援的格式」
|
||||
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
|
||||
t.Errorf("docs 有讀不了/不收的檔,卻一個都沒分類到:%+v", docs)
|
||||
}
|
||||
|
||||
// 每個節點都要有名字與父親,畫面才串得成一棵樹(少一環就變一堆浮著的節點)
|
||||
for _, n := range tree.Nodes {
|
||||
if n.Path != "" && n.Parent == "-" {
|
||||
t.Errorf("非根節點 %q 的 parent 是 '-'(只有根才准)", n.Path)
|
||||
}
|
||||
if n.Name == "" {
|
||||
t.Errorf("節點 %q 沒有名字", n.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── leo 的驗收情境②:空資料夾也要出現(arcrun-rag#106)────────────────────────
|
||||
|
||||
func TestFolderTree空資料夾照樣有根節點(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
if len(tree.Nodes) != 1 || tree.Nodes[0].Path != "" {
|
||||
t.Fatalf("空資料夾應恰好有一個根節點:%v", nodePaths(tree))
|
||||
}
|
||||
if tree.Nodes[0].TotalFiles != 0 {
|
||||
t.Errorf("空資料夾的分母應為 0:%+v", tree.Nodes[0])
|
||||
}
|
||||
// leo:「不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?」
|
||||
// ⇒ 樹要送得出去(有 library、有 root),收端才登記得起這個庫。
|
||||
if tree.Library == "" || tree.Root == "" {
|
||||
t.Errorf("空資料夾的樹缺 library/root,收端登記不了:%+v", tree)
|
||||
}
|
||||
}
|
||||
|
||||
// 走進得去但一個檔都沒有的子資料夾,也要有自己的節點
|
||||
//(leo 的規格是「不管那層有沒有文件,整棵樹都要採」)。
|
||||
func TestFolderTree空的子資料夾也有節點(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.md"), "# A")
|
||||
if err := os.MkdirAll(filepath.Join(root, "打算放東西的地方"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
n := treeNodeOf(t, tree, "打算放東西的地方")
|
||||
if n.TotalFiles != 0 || n.Skipped {
|
||||
t.Errorf("空的子資料夾應該是「有節點、數字 0、沒被剪掉」:%+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 冪等與雜湊 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFolderTreeHash不含時間(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.md"), "# A")
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
plan := PlanIngest(root)
|
||||
payload, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t1 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1000, 0))
|
||||
t2 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(9999, 0))
|
||||
if t1.Hash() != t2.Hash() {
|
||||
t.Error("同一份內容在不同時間算出不同雜湊 ⇒ 冪等閘失效,會每輪重送")
|
||||
}
|
||||
if t1.Hash() == "" {
|
||||
t.Error("雜湊算不出來")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 上傳行為:整棵一次送、成功後節流、失敗退避 ──────────────────────────────
|
||||
|
||||
func TestSyncFolderTree整棵一次送(t *testing.T) {
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
t.Errorf("打錯端點:%s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Arcrun-API-Key") == "" {
|
||||
t.Error("沒帶 X-Arcrun-API-Key(收端會 401)")
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
bodies = append(bodies, m)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
for i := 0; i < 50; i++ { // 遠超過舊版 20 個節點一批的分批門檻
|
||||
mustWrite(t, filepath.Join(root, "d"+string(rune('a'+i%26))+string(rune('a'+i/26)), "x.md"), "# X")
|
||||
}
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
if len(tree.Nodes) <= 20 {
|
||||
t.Fatalf("前置沒建出足夠節點:%d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
now := time.Unix(1786900000, 0)
|
||||
res := syncFolderTree(cfg, root, m, tree, false, now)
|
||||
if res == nil || res.Status != "ingested" {
|
||||
t.Fatalf("應送達:%+v", res)
|
||||
}
|
||||
// 🔴 一次請求送完整棵樹:分批只在「收端每節點一次寫入」的設計下才有意義,
|
||||
// 而收端改成整棵覆寫後,分批只會留下半棵樹的中間狀態。
|
||||
if len(bodies) != 1 {
|
||||
t.Fatalf("應恰好一次請求,實際 %d 次", len(bodies))
|
||||
}
|
||||
sent, _ := bodies[0]["nodes"].([]any)
|
||||
if len(sent) != len(tree.Nodes) {
|
||||
t.Errorf("送出的節點數 %d ≠ 樹的節點數 %d(有被切掉)", len(sent), len(tree.Nodes))
|
||||
}
|
||||
if bodies[0]["sync_token"] == "" || bodies[0]["library"] != "kb" {
|
||||
t.Errorf("body 缺 sync_token/library:%+v", bodies[0])
|
||||
}
|
||||
|
||||
// 內容沒變 → 不重送
|
||||
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Hour)); again != nil {
|
||||
t.Errorf("內容沒變不該重送:%+v", again)
|
||||
}
|
||||
|
||||
// 內容變了、但還在最小間隔內 → 也不送(沒有這道閘,初次同步會每 5 秒送一次)
|
||||
changed := tree
|
||||
changed.Nodes = append([]FolderNode{{Path: "新", Name: "新", Parent: "", Depth: 1}}, tree.Nodes...)
|
||||
if soon := syncFolderTree(cfg, root, m, changed, false, now.Add(30*time.Second)); soon != nil {
|
||||
t.Errorf("最小間隔內不該送:%+v", soon)
|
||||
}
|
||||
// 過了最小間隔 → 送
|
||||
if later := syncFolderTree(cfg, root, m, changed, false, now.Add(folderTreeMinInterval+time.Second)); later == nil {
|
||||
t.Error("過了最小間隔應該要送")
|
||||
}
|
||||
if len(bodies) != 2 {
|
||||
t.Errorf("應總共兩次請求,實際 %d", len(bodies))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFolderTree首次不受最小間隔限制(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root) // 空資料夾
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
// FolderTreeNextSend 是零值(從沒送過)⇒ 就算「現在」小於它也不能被擋
|
||||
// ——指定資料夾的當下就要看得見它,那正是 #106 的重點。
|
||||
if res := syncFolderTree(&DirectConfig{CypherURL: srv.URL, APIKey: "demo"}, root, m, tree, false, time.Unix(1, 0)); res == nil || res.Status != "ingested" {
|
||||
t.Fatalf("首次應直接送出:%+v", res)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("應打一次,實際 %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFolderTree失敗退避(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tree := buildTreeFromDisk(t, root)
|
||||
cfg := &DirectConfig{CypherURL: "https://x.invalid", APIKey: "demo"}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
now := time.Unix(1786900000, 0)
|
||||
res := syncFolderTree(cfg, root, m, tree, false, now)
|
||||
if res == nil || res.Status != "failed" {
|
||||
t.Fatalf("應失敗:%+v", res)
|
||||
}
|
||||
if m.FolderTreeFailHash == "" || m.FolderTreeNextRetry == 0 {
|
||||
t.Errorf("失敗沒記退避 ⇒ 雲端一壞就每 5 秒重撞一次:%+v", m)
|
||||
}
|
||||
// 退避窗口內同一份內容不重撞
|
||||
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Minute)); again != nil {
|
||||
t.Errorf("退避窗口內不該重試:%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// foldertree_traversal_test.go — 「雲端的樹要看得到所有的 folder」的迴歸網
|
||||
// (inkstone/Arcrun#180,leo 2026-08-28)。
|
||||
//
|
||||
// leo 給的判準是機械的,本檔就照它寫:
|
||||
//
|
||||
// find <root> -type d | wc -l == 樹的 total_nodes
|
||||
// (實測差距:youlinhsieh-test1 地端 12 個目錄,雲端的樹只有 3 個節點)
|
||||
//
|
||||
// 🔴 這一組釘死的是「**展開 ≠ 收檔**」:收不收是另一個判斷,
|
||||
// 不准再用「不走進去」來實作「不收」——那會讓使用者連自己有哪些子資料夾都看不到。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// countLocalDirs=`find <root> -type d -not -path '*/.*'` 的 Go 版(含根自己)。
|
||||
func countLocalDirs(t *testing.T, root string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if p != root && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
n++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func treeOf(t *testing.T, root string) FolderTree {
|
||||
t.Helper()
|
||||
m := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||
plan := PlanIngest(root)
|
||||
p, err := Scan(root, m, ScanOptions{Plan: plan})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return BuildFolderTree(root, "lib", p.DirStats, m.Entries, p.AllExcludedDirs, plan, time.Now())
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 這棵樹照著 leo 貼的 `youlinhsieh-test1` 真實形狀擺:
|
||||
// 一個「一般資料夾」(mode=all),底下一整棵 system-dev——以前整棵被剪掉。
|
||||
func fixtureLikeTest1(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
// 範本鋪下去的那幾個,內容一律從內嵌快照原樣取——判準是「內容逐字相同」
|
||||
// (templateuntouched.go),自己捏一段假字串會被正確地判成「使用者寫的」。
|
||||
snap := func(rel string) string {
|
||||
data, err := templateFS.ReadFile(templateFSRoot + "/" + rel)
|
||||
if err != nil {
|
||||
t.Fatalf("快照裡沒有 %s:%v", rel, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
write(t, filepath.Join(root, "火星座標_短片劇本_v1.md"), "# 火星座標\n")
|
||||
write(t, filepath.Join(root, "scripts", "sdd-active-check.sh"), snap("scripts/sdd-active-check.sh"))
|
||||
write(t, filepath.Join(root, "system-dev", "VERSION"), snap("system-dev/VERSION"))
|
||||
write(t, filepath.Join(root, "system-dev", "wiki", "status.md"), snap("system-dev/wiki/status.md"))
|
||||
// 快照裡沒有的真內容——leo 要的那 9 張卡就是這一種
|
||||
write(t, filepath.Join(root, "system-dev", "wiki", "cards", "arcrun-火星座標_短片劇本_v1.md"), "# 卡\n")
|
||||
write(t, filepath.Join(root, "system-dev", "docs", "3-specs", "TEMPLATE-sdd", "design.md"), snap("system-dev/docs/3-specs/TEMPLATE-sdd/design.md"))
|
||||
write(t, filepath.Join(root, "system-dev", "workflows", "tasks-project-sync.yaml"), snap("system-dev/workflows/tasks-project-sync.yaml"))
|
||||
return root
|
||||
}
|
||||
|
||||
func TestFolderTree_地端有幾個資料夾樹上就要有幾個節點(t *testing.T) {
|
||||
root := fixtureLikeTest1(t)
|
||||
want := countLocalDirs(t, root) // 根+scripts+system-dev+wiki+cards+docs+3-specs+TEMPLATE-sdd+workflows = 9
|
||||
tree := treeOf(t, root)
|
||||
if tree.TotalNodes != want {
|
||||
var got []string
|
||||
for _, n := range tree.Nodes {
|
||||
got = append(got, n.Path)
|
||||
}
|
||||
t.Fatalf("樹的節點數 %d ≠ 地端資料夾數 %d\n樹上有:%v", tree.TotalNodes, want, got)
|
||||
}
|
||||
if tree.Truncated {
|
||||
t.Fatalf("這麼小的一棵樹不該被截斷")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderTree_不收的那一層仍然展得開而且說得出理由(t *testing.T) {
|
||||
root := fixtureLikeTest1(t)
|
||||
tree := treeOf(t, root)
|
||||
byPath := map[string]FolderNode{}
|
||||
for _, n := range tree.Nodes {
|
||||
byPath[n.Path] = n
|
||||
}
|
||||
|
||||
// ① system-dev 底下每一層都在(以前整棵不存在)
|
||||
for _, want := range []string{
|
||||
"system-dev", "system-dev/wiki", "system-dev/wiki/cards",
|
||||
"system-dev/docs", "system-dev/docs/3-specs",
|
||||
"system-dev/docs/3-specs/TEMPLATE-sdd", "system-dev/workflows",
|
||||
} {
|
||||
if _, ok := byPath[want]; !ok {
|
||||
t.Fatalf("樹上少了 %q——「不收」又被實作成「不走進去」了", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 數字是真的:cards 底下實際有 1 個檔,不是我們編的 0;
|
||||
// 而且它是真內容 ⇒ 要被收(leo:「cards 在 system-dev 下…所以不允許跳過它」)
|
||||
if got := byPath["system-dev/wiki/cards"]; got.TotalFiles != 1 || got.ExcludedFiles != 0 {
|
||||
t.Fatalf("cards:total=%d excluded=%d,期望 1/0(走進去了要數真的,而且真內容要收)",
|
||||
got.TotalFiles, got.ExcludedFiles)
|
||||
}
|
||||
|
||||
// ③ 一個檔都沒收的那幾層(全是範本空殼),理由要講得出來
|
||||
for _, p := range []string{"system-dev/wiki", "scripts", "system-dev/workflows"} {
|
||||
n := byPath[p]
|
||||
if n.ExcludedFiles != n.TotalFiles || n.TotalFiles == 0 {
|
||||
t.Fatalf("%s 期望「全部排除」,實際 total=%d excluded=%d", p, n.TotalFiles, n.ExcludedFiles)
|
||||
}
|
||||
if !n.Skipped || n.SkipReason == "" {
|
||||
t.Fatalf("%s 一個檔都沒收,卻沒有講理由(skipped=%v reason=%q)", p, n.Skipped, n.SkipReason)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 收檔範圍沒有跟著放寬——範本檔仍然不是知識
|
||||
if n := byPath[""]; n.SyncedFiles+n.PendingFiles == 0 {
|
||||
t.Fatalf("根層那份真文件應該還是要收")
|
||||
}
|
||||
}
|
||||
|
||||
// 剪枝只剩「走進去也沒有意義」的那幾種,而且仍然留節點+理由。
|
||||
func TestFolderTree_依賴目錄仍然剪枝但看得見(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write(t, filepath.Join(root, "筆記.md"), "# 筆記\n")
|
||||
write(t, filepath.Join(root, "node_modules", "left-pad", "index.js"), "module.exports=1\n")
|
||||
tree := treeOf(t, root)
|
||||
|
||||
var nm *FolderNode
|
||||
for i := range tree.Nodes {
|
||||
if tree.Nodes[i].Path == "node_modules" {
|
||||
nm = &tree.Nodes[i]
|
||||
}
|
||||
if strings.HasPrefix(tree.Nodes[i].Path, "node_modules/") {
|
||||
t.Fatalf("不該走進 node_modules,卻生出了 %q", tree.Nodes[i].Path)
|
||||
}
|
||||
}
|
||||
if nm == nil {
|
||||
t.Fatal("node_modules 剪掉了就更該列出來——安靜地少收與講一個 0 是同一件事")
|
||||
}
|
||||
if !nm.Skipped || nm.SkipReason == "" {
|
||||
t.Fatalf("node_modules 沒有理由:skipped=%v reason=%q", nm.Skipped, nm.SkipReason)
|
||||
}
|
||||
}
|
||||
+221
-44
@@ -62,6 +62,9 @@ type IngestPlan struct {
|
||||
// WikiRelDir=現成 wiki 的相對路徑(僅 IngestCuratedWiki)。
|
||||
WikiRelDir string `json:"wiki_rel_dir,omitempty"`
|
||||
// DocRelDirs=要收的文件目錄(僅 IngestDocsOnly;根層 .md 另由 keepsRootDoc 放行)。
|
||||
// 來源有兩種,合併後去重排序(見 mergeDocDirs):固定三名字(existingDocDirs)
|
||||
// + Phase 0 內容判準自動找到的非標準命名文件目錄(scanAuxDirs 的 autoDocDirs,
|
||||
// `arcrun-rag#136`)。
|
||||
DocRelDirs []string `json:"doc_rel_dirs,omitempty"`
|
||||
// Reason=一句話講給使用者聽的「為什麼只收這些」。
|
||||
Reason string `json:"reason"`
|
||||
@@ -157,15 +160,30 @@ var toolOwnedFileNames = map[string]bool{
|
||||
"Gemfile.lock": true, "poetry.lock": true, "pnpm-workspace.yaml": true,
|
||||
}
|
||||
|
||||
// templateOwnedDirNames=system-dev-template 鋪出來的產物區,任何深度都不當原稿
|
||||
// (daemon-beta task 2,2026-08-06,原始出處 commit b4fee43)。
|
||||
// 🔴 這裡以前有第四張表:`templateOwnedDirNames = {"system-dev": true}`
|
||||
// ——「目錄名等於 system-dev ⇒ 任何深度、所有模式,整棵剪掉」。
|
||||
// **inkstone/Arcrun#180(leo 2026-08-28)把它拿掉了,但不是放行,是換成分辨。**
|
||||
//
|
||||
// 🔴 這一條以前是 direct.go 自己手捏的第二張表(`SkipDirNames{"system-dev": true}`),
|
||||
// 而 #104 的排除清單在這裡——**兩張表分居兩處,於是 2026-08-16 讀源碼的人只看到其中一張,
|
||||
// 把「清單根本沒接上」當成了真兇**(實際上兩張都有接上,見 direct.go 的 `Plan: plan`)。
|
||||
// 收成同一個地方,就不會再有第二張表可以漏看。
|
||||
// curated-wiki 模式要收的正是 `system-dev/wiki` ⇒ 那條路由 onPathTo 放行。
|
||||
var templateOwnedDirNames = map[string]bool{"system-dev": true}
|
||||
// leo 的原話:「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**,
|
||||
// 而且**所有的 system-dev 都可以展開,因為就算沒有可萃的它也有下層**。」
|
||||
//
|
||||
// 實據(`~/Desktop/youlinhsieh-test1`):
|
||||
//
|
||||
// system-dev/wiki/ ← 6 個範本空殼(Jul 28 同一秒鋪下來的)
|
||||
// system-dev/wiki/cards/ ← 9 張真的卡(火星座標/Leo-Hsieh-填答/小果被AFTEE詐貸…)
|
||||
//
|
||||
// 一張名字表沒有辦法分辨這兩層——它只看得到「這個目錄叫 system-dev」,
|
||||
// 於是把空殼和真內容一起丟掉,而且**連底下有哪些子資料夾都沒生出來**
|
||||
// (地端 find 有 13 個目錄,雲端的樹只有 3 個節點)。
|
||||
//
|
||||
// 換掉的是問題本身:
|
||||
//
|
||||
// 舊:這個目錄叫什麼名字? ⇒ 整棵剪掉
|
||||
// 新:這一個檔,和安裝器鋪下去的原版一不一樣? ⇒ 逐檔決定(templateuntouched.go)
|
||||
//
|
||||
// 空殼與原版逐字相同 ⇒ 照舊不收(紅線①:不准變成放行);
|
||||
// 使用者寫過、或安裝器根本沒鋪過的檔(那 9 張卡)⇒ 收。
|
||||
// 而目錄一律照走 ⇒ 樹長得出來(紅線②:展開 ≠ 收檔)。
|
||||
|
||||
// projectManifestFiles=「有人在這一層跑建置工具」的佐證檔。
|
||||
//
|
||||
@@ -237,7 +255,7 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
}
|
||||
|
||||
evidence := shape.Evidence()
|
||||
others := otherWikiDirs(absRoot)
|
||||
aux := scanAuxDirs(absRoot)
|
||||
if wiki := findCuratedWiki(absRoot); wiki != "" {
|
||||
return IngestPlan{
|
||||
Mode: IngestCuratedWiki,
|
||||
@@ -245,12 +263,12 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
WikiRelDir: wiki,
|
||||
Reason: "這是一個開發專案(" + evidence + "),而且你已經整理好一份知識庫(" + wiki + ")——" +
|
||||
"我直接讀那一份就好,不再把整個專案的原始碼與零散檔案重萃一次。",
|
||||
OtherWikiDirs: others,
|
||||
OtherWikiDirs: aux.otherWikiDirs,
|
||||
ignore: ignore,
|
||||
}
|
||||
}
|
||||
|
||||
docs := existingDocDirs(absRoot)
|
||||
docs := mergeDocDirs(existingDocDirs(absRoot), aux.autoDocDirs)
|
||||
reason := "這是一個開發專案(" + evidence + "),我只讀文件、不讀程式碼。"
|
||||
if len(docs) > 0 {
|
||||
reason = "這是一個開發專案(" + evidence + "),我只讀文件(" +
|
||||
@@ -261,11 +279,33 @@ func PlanIngest(absRoot string) IngestPlan {
|
||||
Shape: shape,
|
||||
DocRelDirs: docs,
|
||||
Reason: reason,
|
||||
OtherWikiDirs: others,
|
||||
OtherWikiDirs: aux.otherWikiDirs,
|
||||
ignore: ignore,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeDocDirs 合併「固定三名字」與「內容判準自動找到的」兩份文件目錄清單,去重排序。
|
||||
// 兩份清單本來就可能重疊(例如根層真的叫 `docs` 又剛好零程式碼)——去重才不會讓
|
||||
// Reason 文案讀起來像「docs、docs」。
|
||||
func mergeDocDirs(fixed, auto []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range fixed {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for _, d := range auto {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// findCuratedWiki 回傳第一個「存在且真的有內容」的現成 wiki 目錄(相對路徑),沒有回空字串。
|
||||
//
|
||||
// 「有內容」=底下至少有一個 `.md`。空目錄不算——不然一個剛跑完 template 安裝、
|
||||
@@ -315,24 +355,38 @@ func existingDocDirs(absRoot string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// otherWikiDirs 找出監看根底下**其他**地方的 wiki(子專案自己的知識庫)。
|
||||
// auxDirScan=一次全樹走訪同時算出來的兩種結果(合併走訪,見 scanAuxDirs)。
|
||||
type auxDirScan struct {
|
||||
otherWikiDirs []string // 子專案自己的 wiki(找到但刻意不收,只講出來)
|
||||
autoDocDirs []string // Phase 0:內容判準找到的「非標準命名文件目錄」(要收)
|
||||
}
|
||||
|
||||
// scanAuxDirs 走訪一次監看根,同時算出「其他子專案的 wiki 在哪」與
|
||||
// 「Phase 0:非標準命名但整棵零程式碼的文件目錄」(`inkstone/arcrun-rag#136`
|
||||
// leo 2026-08-24 第三次追加,優先度高於樹狀 UI)。
|
||||
//
|
||||
// 🔴 為什麼找出來卻不收:leo 的 `InkStoneCo` 底下有 `products/*`、`matrix/*` 這些
|
||||
// **各自獨立的 repo**,每一個都有自己的 `system-dev/wiki`。它們是**別的專案**的知識,
|
||||
// 混進這個資料夾的知識庫裡,AR-Mira 搜一個主題就會回一堆分不清屬於誰的東西。
|
||||
// 要收哪一個,是使用者的決定——把那個子專案自己加進看守清單即可。
|
||||
// 🔴 兩者原本是兩支各自獨立的函式(otherWikiDirs/新的內容判準),這裡合併成一次
|
||||
// 走訪——理由是研究文件(`docs-only-skip-visibility-override-research.md` §2.5)
|
||||
// 明講的經濟性:兩者都要「走一次監看根、套用同一組排除規則、在沒被排除的節點上
|
||||
// 做判斷」,差別只在判斷內容。合併之後每輪同步只走一次樹,不是兩次。
|
||||
//
|
||||
// 但**一定要講出來**:使用者接了一個 monorepo 卻只看到 32 張卡,不告訴他其餘的在哪,
|
||||
// 他只會覺得東西不見了(票上的紅線:不要讓他猜)。
|
||||
// 🔴 合併還解決了一個正確性問題(不是效能問題):如果分開各自跑一次 WalkDir,
|
||||
// 一個子專案自己的 `xxx/wiki/` 會被 otherWikiDirs 判定「找到但刻意不收」,
|
||||
// 但新的內容判準若獨立走訪,會用「零程式碼+有文件」的邏輯把同一個 `wiki` 目錄
|
||||
// **又收了一次**,直接推翻 otherWikiDirs 那條「這是別人的知識,不混進來」的
|
||||
// 既有設計(該函式原本的說明就在解釋為什麼刻意不收)。同一次走訪、同一個節點只判
|
||||
// 一次,兩件事天生不會互相打架。
|
||||
//
|
||||
// 走訪時套用與 Scan 相同的跳過規則(隱藏目錄、noise、linked worktree、
|
||||
// 已知的 curated 位置自己),所以出貨用 worktree 與 templatefs 的那十幾份不會列進來。
|
||||
func otherWikiDirs(absRoot string) []string {
|
||||
seen := map[string]bool{}
|
||||
// 🔴 判準只在**通過既有排除規則、沒被任何一條攔下**的節點上跑(隱藏目錄、
|
||||
// `toolOwnedDirNames`、`ambiguousBuildDirNames`+`looksGenerated`、linked worktree)
|
||||
// ——與 `SkipsDirWhy` 的優先序一致,所以 `node_modules` 底下的任何內容
|
||||
// **根本沒有機會被走到**,不會重新引入 `#104` 的套件洩漏洞。
|
||||
func scanAuxDirs(absRoot string) auxDirScan {
|
||||
seenWiki := map[string]bool{}
|
||||
for _, rel := range curatedWikiCandidates {
|
||||
seen[rel] = true
|
||||
seenWiki[rel] = true
|
||||
}
|
||||
var out []string
|
||||
var result auxDirScan
|
||||
_ = filepath.WalkDir(absRoot, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() || p == absRoot {
|
||||
return nil
|
||||
@@ -351,14 +405,71 @@ func otherWikiDirs(absRoot string) []string {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if name == "wiki" && !seen[relSlash] && dirHasMarkdown(p) {
|
||||
out = append(out, relSlash)
|
||||
|
||||
// ① 別人的 wiki——找到就講出來,但不收、不繼續往下走訪(其餘同舊行為)。
|
||||
if name == "wiki" && !seenWiki[relSlash] && dirHasMarkdown(p) {
|
||||
result.otherWikiDirs = append(result.otherWikiDirs, relSlash)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// ② Phase 0:這個目錄「自己直接放的檔案」零程式碼、且至少一個文件類副檔名
|
||||
// ⇒ 即使名字不叫 docs,也當文件目錄收。
|
||||
//
|
||||
// 🔴 只看「這個目錄自己直接放的檔案」,不遞迴檢查整棵子樹——
|
||||
// 研究文件原本建議整棵子樹零程式碼才算,但真實案例
|
||||
// (`pms_v1_legacy` 巢狀 `pms-backup/pms_db_backup.sql`)證明那樣會讓
|
||||
// `pms_v1_legacy` 自己直接放的 `PMS_USER_STORIES.md`/`PMS_GAP_ANALYSIS.md`
|
||||
// 因為巢狀兩層深的一個 .sql 備份檔而整批繼續被跳過——治標的判準沒解決
|
||||
// 票上真正的案例。改成逐層各自判斷之後,`pms_v1_legacy` 用自己的直接內容
|
||||
// 合格,`pms-backup`(自己直接放著 .sql)不合格但不影響外層,而巢狀更深的
|
||||
// `pms-backup/.wiki`(自己直接內容零程式碼+有 .md)又重新合格——
|
||||
// 這與 Scan/畫面本來就「每個節點只算自己直接放的檔案」(`total_files` 等
|
||||
// 欄位的既有語意,見 `collector/foldertree.go`)一致,不是另立一套算法。
|
||||
//
|
||||
// 合格就整棵收(`SkipsDirWhy`/`KeepsFile` 的 `onPathTo` 前綴比對本來就會
|
||||
// 涵蓋子孫),不必再往下走訪找子孫裡還有沒有另一個合格點。
|
||||
// 不合格則繼續遞迴——巢狀更深處仍可能有獨立合格的文件子目錄。
|
||||
if !seenWiki[relSlash] && dirQualifiesAsAutoDoc(p) {
|
||||
result.autoDocDirs = append(result.autoDocDirs, relSlash)
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(out)
|
||||
return out
|
||||
sort.Strings(result.otherWikiDirs)
|
||||
sort.Strings(result.autoDocDirs)
|
||||
return result
|
||||
}
|
||||
|
||||
// dirQualifiesAsAutoDoc 回答「這個目錄自己直接放的檔案,算不算文件目錄」——
|
||||
// Phase 0 的核心判準(`inkstone/arcrun-rag#136`):零程式碼副檔名+至少一個
|
||||
// 文件類副檔名。**只看直接放在這個目錄裡的檔案**,不遞迴看子目錄(見呼叫端說明)。
|
||||
//
|
||||
// 🔴 「零程式碼」而非「程式碼佔比低於門檻」:leo 08-24 第三次留言明講不要用比例——
|
||||
// 一個真正的程式碼目錄(`scripts/` 這種文件寫得多的)不該因為比例低就被誤判成文件夾。
|
||||
// 副檔名表沿用既有的(`codeFileExts`/`allowedExt`/`docLikeExt`),不重新發明一套。
|
||||
func dirQualifiesAsAutoDoc(absDir string) bool {
|
||||
entries, err := os.ReadDir(absDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hasDoc := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if codeFileExts[ext] {
|
||||
return false // 一個程式碼副檔名就整個不合格——零門檻,見上方說明
|
||||
}
|
||||
if allowedExt[ext] || docLikeExt[ext] {
|
||||
hasDoc = true
|
||||
}
|
||||
}
|
||||
return hasDoc
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -367,13 +478,23 @@ func otherWikiDirs(absRoot string) []string {
|
||||
|
||||
// SkipsDir 回答「走訪時要不要整棵跳過這個目錄」。relSlash 是相對監看根的路徑。
|
||||
//
|
||||
// 三類跳過,全模式適用:
|
||||
// 1. noise(依賴/建置產物/範本)——名字判準,見 noiseDirNames
|
||||
// 2. linked worktree——`.git` 是檔案。#104 實據裡的 `.claude/worktrees/…` 與
|
||||
// `products/arcrun-rag-wt60ship` 全是這一種:主 repo 的第二份簽出,收了就是重複
|
||||
// 3. 巢狀 repo(自己有 `.git` 的子目錄)——那是別的專案,見 otherWikiDirs 的說明
|
||||
// 🔴 **「不走進去」與「不收這裡的檔」是兩件事**(inkstone/Arcrun#180 紅線②,
|
||||
// leo 2026-08-28:「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」)。
|
||||
// 這支只回答前者,而且**只剩「走進去也沒有意義」的那幾種**:
|
||||
//
|
||||
// 再加上模式限定的:curated-wiki 只走那一份 wiki 的路;docs-only 只走文件目錄的路。
|
||||
// 1. 使用者自己宣告不要的(`.gitignore`)
|
||||
// 2. 工具產生的(別人的套件、快取、建置產物)——名字本身就不是人話
|
||||
// 3. 泛用名的建置目錄(旁邊擺著產生它的專案檔才算)
|
||||
// 4. linked worktree——同一個 repo 的第二份簽出,整棵是重複的
|
||||
// 5. 巢狀 repo——那是另一個獨立專案,要收請自己加進看守清單
|
||||
//
|
||||
// 這五種每一種都是「裡面長什麼樣不必知道」,剪掉不會讓使用者少看到自己的東西
|
||||
// (而且 `node_modules` 這種走進去就是幾萬個節點,畫面反而看不了)。
|
||||
//
|
||||
// **拿掉的是「收檔範圍」那幾條**:以前 curated-wiki 只走那份 wiki 的路、docs-only 只走
|
||||
// 文件區、範本目錄整棵剪掉——那三條講的是「要不要**收**」,卻長在剪枝上,
|
||||
// 於是樹在那一節就斷了(實測:地端 13 個目錄,雲端的樹只有 3 個節點)。
|
||||
// 它們搬去 CollectsDirWhy,只影響收檔,不影響走訪。
|
||||
// (隱藏目錄由 Scan 自己擋,那條規則比本檔更早存在,不搬過來。)
|
||||
func (p IngestPlan) SkipsDir(relSlash, absPath string) bool {
|
||||
skip, _ := p.SkipsDirWhy(relSlash, absPath)
|
||||
@@ -400,10 +521,6 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) {
|
||||
if ambiguousBuildDirNames[name] && looksGenerated(absPath) {
|
||||
return true, "這是建置工具產生的目錄(旁邊就是產生它的專案檔)"
|
||||
}
|
||||
// ④ template 鋪出來的產物區(任何深度)。curated-wiki 要收的那條路例外。
|
||||
if templateOwnedDirNames[name] && !(p.Mode == IngestCuratedWiki && onPathTo(relSlash, p.WikiRelDir)) {
|
||||
return true, "這是開發範本鋪出來的目錄,不是你的知識"
|
||||
}
|
||||
if IsLinkedWorktree(absPath) {
|
||||
return true, "這是同一個專案的第二份簽出(git worktree),內容與主資料夾重複"
|
||||
}
|
||||
@@ -411,21 +528,31 @@ func (p IngestPlan) SkipsDirWhy(relSlash, absPath string) (bool, string) {
|
||||
if IsRepoRoot(absPath) {
|
||||
return true, "這是另一個獨立的專案,要收請把它自己加進看守清單"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// CollectsDirWhy 回答「這一層的檔案,這次收不收」——**走訪照走,只影響收檔**。
|
||||
//
|
||||
// 回 (false, 理由) = 這一層的檔不收,理由講給使用者聽;(true, "") = 照收檔規則走。
|
||||
//
|
||||
// 這裡裝的正是從剪枝搬過來的那幾條收檔範圍:curated-wiki 只讀那份整理好的 wiki、
|
||||
// docs-only 只讀文件區。**它們是好的判斷,錯的只是它們以前長在剪枝上**
|
||||
// ——把「不收」實作成「不走進去」,代價是使用者連自己有哪些子資料夾都看不到。
|
||||
func (p IngestPlan) CollectsDirWhy(relSlash string) (bool, string) {
|
||||
switch p.Mode {
|
||||
case IngestCuratedWiki:
|
||||
// 只有「通往那份 wiki 的路」與「那份 wiki 底下」要走。
|
||||
if !onPathTo(relSlash, p.WikiRelDir) {
|
||||
return true, "這次只讀你整理好的 " + p.WikiRelDir
|
||||
return false, "這次只讀你整理好的 " + p.WikiRelDir
|
||||
}
|
||||
case IngestDocsOnly:
|
||||
for _, d := range p.DocRelDirs {
|
||||
if onPathTo(relSlash, d) {
|
||||
return false, ""
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return true, "這是一個開發專案,這次只讀文件區,不讀程式碼"
|
||||
return false, "這是一個開發專案,這次只讀文件區,不讀程式碼"
|
||||
}
|
||||
return false, ""
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// KeepsFile 回答「這個檔要不要收」。relSlash 是相對監看根的路徑。
|
||||
@@ -443,6 +570,56 @@ func (p IngestPlan) KeepsFile(relSlash string) bool {
|
||||
if toolOwnedFileNames[filepath.Base(relSlash)] {
|
||||
return false
|
||||
}
|
||||
// 🔴 inkstone/Arcrun#134(2026-08-28 實測):**我們自己產的卡不是原稿。**
|
||||
//
|
||||
// curated-wiki 模式收的是「他親手整理的 `system-dev/wiki/`」,而那個目錄底下
|
||||
// 同時住著 daemon 上一版寫進去的卡(`cards/arcrun-*.md`,帶 MachineMark)。
|
||||
// 少了這一問,daemon 會把自己 8/4 產的卡當成新原稿再萃一次,得到「卡片的卡片」
|
||||
// ——實測 `youlinhsieh-test1` 一輪長出 10 份第二代文件卡(`arcrun-換柱`、
|
||||
// `arcrun-官架子`…),而它們又會變成第三代的原稿。
|
||||
//
|
||||
// 判準用 IsMachineOwnedRel 而不是「在不在某個目錄」:那支是規約的完整判準
|
||||
// (帶 MachineMark 前綴 ∪ 路徑上有 `.arcrun-rag/` 或 `.wiki/`),
|
||||
// 而使用者自己寫的卡不帶前綴 ⇒ 照收不誤,curated-wiki 的規格(#104 第二層,
|
||||
// 「那份 wiki 是他寫的,正是他要我們讀的」)不受影響。
|
||||
if InMachineOwnedDir(relSlash) {
|
||||
return false
|
||||
}
|
||||
// 🔴 inkstone/Arcrun#180(2026-08-28)把上面那一問**收窄成只在 curated-wiki 生效**,
|
||||
// 因為 #134 與 #180 的迴歸網在同一個路徑上要求相反的答案:
|
||||
//
|
||||
// #134 cardloop_test.go:`system-dev/wiki/cards/arcrun-換柱.md` **不准收**
|
||||
// #180 template_not_knowledge_test.go:`system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md` **必須收**
|
||||
//
|
||||
// 同一個目錄、同一個前綴 ⇒ **靠名字分不開**,實測 #151 併入後四種模式全部回 false,
|
||||
// leo 的那 9 張卡一張都收不到(而他 08-14 的原話是「這些庫都早就萃好了⋯⋯直接 ingest」,
|
||||
// #180 票上第 3 條驗收就是「那 9 張卡的內容進得了雲端」)。
|
||||
//
|
||||
// 收窄的依據是 #134 自己寫下的理由——它整段講的是 curated-wiki:
|
||||
// 「curated-wiki 模式收的是『他親手整理的 system-dev/wiki/』,而那個目錄底下
|
||||
// 同時住著 daemon 上一版寫進去的卡」。**接一個開發 repo 時那個重疊是結構性的**
|
||||
//(`cardsRelDir` 就等於 `system-dev/wiki/cards`)⇒ 該擋。
|
||||
// 一般資料夾/筆記庫(mode=all)沒有那個結構性重疊,而 leo 對這一種明確表過態。
|
||||
//
|
||||
// 🔴 **這一格是我的假設,不是 leo 的裁決**:兩張票的驗收互斥,需要他選一邊。
|
||||
// 假設寫在這裡與 PR/票上,錯了打回即可(`inkstone/Arcrun#180`)。
|
||||
// 迴圈安不安全:#134 三個環裡真正關掉迴圈的是 tidy/snapshotCards 那兩個
|
||||
//(產物只落 `.wiki/`,上面那一問就擋得住)。實測 4 輪 mode=all 收這 9 張卡,
|
||||
// `system-dev/wiki/cards/` 從頭到尾維持 9 個檔、沒有第二代。
|
||||
//
|
||||
// 🔴 第二格收窄:**再加一問「在不在卡片產物區」**。
|
||||
// `IsMarked` 只看 basename 前綴,而 `arcrun-` 是這個專案的人也會用的命名習慣——
|
||||
// 實測 leo 的 `youlinhsieh-test1` **根層**就有三個他自己寫的檔中招:
|
||||
// `arcrun-1457驗收-20260827.md`/`arcrun-md-test.md`/
|
||||
// `arcrun-複驗用-請勿刪-20260827c.md`(檔名裡就寫著「請勿刪」)
|
||||
// 對照組實跑(#151 原樣、dry-run):這三個連同那 9 張卡共 **12 個檔被排進「下架」**
|
||||
// ——不是不收而已,是**把雲端已經有的內容撤掉**。
|
||||
// #134 要擋的是「daemon 寫在他 wiki 裡的卡」,那些卡一律住在卡片產物區
|
||||
//(`system-dev/wiki/cards/`/`.arcrun-rag/wiki/cards/`,`cardRelFor` 的落點),
|
||||
// 所以多這一問不會放過任何一個它要擋的東西,卻救回使用者自己命名的檔。
|
||||
if p.Mode == IngestCuratedWiki && IsMarked(filepath.Base(relSlash)) && isUnderCardDir(relSlash) {
|
||||
return false
|
||||
}
|
||||
switch p.Mode {
|
||||
case IngestCuratedWiki:
|
||||
return strings.HasPrefix(relSlash, p.WikiRelDir+"/")
|
||||
|
||||
+165
-6
@@ -229,6 +229,15 @@ func TestPlanIngest_ExclusionsAreVisible(t *testing.T) {
|
||||
}
|
||||
|
||||
// 沒有現成 wiki 的 repo:退到「只讀文件、不讀程式碼」(leo 明講的第三步)。
|
||||
//
|
||||
// 🔴 2026-08-27 Phase 0(`arcrun-rag#136`,leo 08-24 第三次追加)之後行為分兩種:
|
||||
// - `src/說明.md`:`src/` 這個目錄自己直接放的檔案裡就有 `.go`(程式碼)——
|
||||
// 零程式碼這一條不成立,繼續當程式碼目錄整棵跳過,不收。
|
||||
// - `internal/notes.md`:`internal/` 這個目錄自己直接放的檔案裡**只有這份 .md**、
|
||||
// 沒有任何程式碼副檔名——即使名字不叫 docs,Phase 0 判準也會把它當文件目錄收。
|
||||
// 這不是誤放的迴歸,是這次要的效果:leo 08-24「如果要加上很多(手動救回),
|
||||
// 那就更覺得很煩,就會棄用」——非標準命名但整棵零程式碼的資料夾預設就該收,
|
||||
// 不必等使用者自己救。
|
||||
func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
@@ -236,8 +245,8 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
"README.md": "# 專案",
|
||||
"docs/請假規則.md": "# 特休 14 天",
|
||||
"docs/報銷政策.md": "# 每日 3000 元",
|
||||
"src/說明.md": "# 散在程式碼旁邊",
|
||||
"internal/notes.md": "# 也是程式碼旁邊",
|
||||
"src/說明.md": "# 散在程式碼旁邊——但 src/ 自己直接放的檔案裡就有 .go,不合格",
|
||||
"internal/notes.md": "# 自己單獨一個資料夾,直接內容零程式碼——Phase 0 判準下合格",
|
||||
} {
|
||||
files[rel] = body
|
||||
}
|
||||
@@ -250,9 +259,11 @@ func TestPlanIngest_RepoWithoutWikiReadsDocsOnly(t *testing.T) {
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||||
}
|
||||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md"}
|
||||
want := []string{"README.md", "docs/報銷政策.md", "docs/請假規則.md", "internal/notes.md"}
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("送了 %v,want %v(程式碼旁邊的 .md 不該收)", got, want)
|
||||
t.Fatalf("送了 %v,want %v(`src/` 因為自己直接放著 .go 仍不收;`internal/` "+
|
||||
"自己直接內容零程式碼,Phase 0 判準下該收)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +582,11 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) {
|
||||
wantGot: []string{"wiki/INDEX.md", "wiki/status.md"},
|
||||
},
|
||||
{
|
||||
// ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔,跳過原始碼
|
||||
// ③ 軟體專案、沒有現成 wiki ⇒ 只收文件區+根層說明檔+跳過原始碼,
|
||||
// 但 Phase 0(`arcrun-rag#136`)之後也收「非標準命名、自己直接內容零程式碼」
|
||||
// 的資料夾——`雜/` 自己只放了一份 `隨手記.md`,沒有任何程式碼副檔名,
|
||||
// 即使名字不叫 docs 也算文件目錄。這不是誤放的迴歸,見同檔
|
||||
// TestPlanIngest_RepoWithoutWikiReadsDocsOnly 上方的完整說明。
|
||||
name: "沒wiki的專案只收文件跳過源碼",
|
||||
files: func() map[string]string {
|
||||
f := merge(codeProjectFiles("", ".go", "package main"))
|
||||
@@ -580,7 +595,7 @@ func TestPlanIngest_三種收法在新判準下都還正確(t *testing.T) {
|
||||
return f
|
||||
}(),
|
||||
wantMode: IngestDocsOnly,
|
||||
wantGot: []string{"README.md", "docs/請假規則.md"},
|
||||
wantGot: []string{"README.md", "docs/請假規則.md", "雜/隨手記.md"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -671,6 +686,150 @@ func TestPlanIngest_判成專案時要講得出憑什麼(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0(`arcrun-rag#136`,leo 2026-08-24 第三次追加)——正面驗收:
|
||||
// 票上真正的案例(`pms_v1_legacy/pms-backup`):非標準命名、整棵沒有一套程式碼、
|
||||
// 但巢狀更深處混了一份資料庫備份檔——這正是研究文件原本「整棵子樹零程式碼」的
|
||||
// 設計會漏掉的那個真實形狀(見 `dirQualifiesAsAutoDoc` 的說明)。
|
||||
//
|
||||
// fixture 照 leo 08-24 給的 Finder 截圖形狀造:
|
||||
//
|
||||
// pms_v1_legacy/
|
||||
// PMS_USER_STORIES.md ← 自己直接放的文件(零程式碼);一旦這裡合格,
|
||||
// 整個 pms_v1_legacy 都被收(onPathTo 前綴涵蓋子孫)
|
||||
// pms-backup/
|
||||
// PMS_ASSESSMENT.md ← 自己直接放的文件
|
||||
// pms_db_backup.sql ← 巢狀更深處的「程式碼副檔名」(codeFileExts 收 .sql)
|
||||
//
|
||||
// 🔴 `pms-backup` 底下混了 .sql 這件事,**不影響** `pms_v1_legacy` 本身的合格判斷
|
||||
// (逐層判準只看每一層自己的直接內容),所以 `pms-backup/PMS_ASSESSMENT.md`
|
||||
// 也跟著被收——它是「已合格的 pms_v1_legacy」底下的子孫,不必再單獨判一次
|
||||
// (與 `docs/` 目錄底下不管有沒有子資料夾都整棵收,是同一條既有語意)。
|
||||
func TestPlanIngest_Phase0非標準命名文件目錄零程式碼即收(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["README.md"] = "# 專案"
|
||||
files["pms_v1_legacy/PMS_USER_STORIES.md"] = "# 使用者故事"
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("策略=%s(%s)", plan.Mode, plan.Reason)
|
||||
t.Logf("送出:%v", got)
|
||||
for _, d := range payload.ExcludedDirs {
|
||||
t.Logf("跳過 %s — %s", d.Path, d.Reason)
|
||||
}
|
||||
|
||||
if plan.Mode != IngestDocsOnly {
|
||||
t.Fatalf("策略=%s,want %s", plan.Mode, IngestDocsOnly)
|
||||
}
|
||||
want := []string{
|
||||
"README.md",
|
||||
"pms_v1_legacy/PMS_USER_STORIES.md", // 自己直接放的檔案零程式碼,合格
|
||||
"pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md", // 已合格祖先底下的子孫,一併收
|
||||
}
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("送出 %v,want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——正面驗收(獨立合格):`pms-backup` 若是「自己單獨掛上去的看守根」
|
||||
// (不在任何已合格祖先底下),逐層判準要能單獨判它自己——它自己直接混了 .sql,
|
||||
// 不合格,但巢狀更深處若有一個自己零程式碼的子目錄,仍要被獨立找到。
|
||||
// 用 `archive/`(非標準命名、非隱藏)取代真實案例裡的 `.wiki`——後者是
|
||||
// daemon 自己產生的隱藏快取目錄,本來就會被 Scan 的隱藏目錄規則整個擋下,
|
||||
// 不是這裡要驗的「內容判準」這件事。
|
||||
func TestPlanIngest_Phase0巢狀更深處的獨立合格目錄也找得到(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
files["pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"] = "# 更早的筆記"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("送出:%v", got)
|
||||
want := "pms_v1_legacy/pms-backup/archive/OLD_NOTES.md"
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p == want {
|
||||
found = true
|
||||
}
|
||||
if strings.HasPrefix(p, "pms_v1_legacy/pms-backup/") && p != want {
|
||||
t.Fatalf("`pms-backup` 自己混了 .sql 不該被收,卻收了 %s", p)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("巢狀更深處的獨立合格目錄沒被找到:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收①:`pms-backup` 自己直接放著 `.sql`(程式碼副檔名),
|
||||
// 所以它自己不合格,同層的 `PMS_ASSESSMENT.md` 目前**仍然不會被收**——
|
||||
// 這是逐層判準(而非整棵子樹判準)刻意接受的邊界:一個目錄自己混了程式碼副檔名,
|
||||
// 就當它自己是「開發用的」,不因為隔壁躺著一份文件就整個放行。使用者若真的要救
|
||||
// 這一份,仍然有第 6 節的樹狀 UI/手動覆寫(尚未實作,見票上 Phase 2)這條路。
|
||||
// 本測試把這個邊界寫清楚,不讓它在下一輪被誤改成「連 pms-backup 自己也收」。
|
||||
func TestPlanIngest_Phase0巢狀混雜程式碼的目錄自己仍不收(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["pms_v1_legacy/pms-backup/PMS_ASSESSMENT.md"] = "# 評估報告"
|
||||
files["pms_v1_legacy/pms-backup/pms_db_backup.sql"] = "-- 假資料,測試只在意副檔名不在意內容\n"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "pms-backup/") {
|
||||
t.Fatalf("`pms-backup` 自己直接放著 .sql(程式碼副檔名),不該被 Phase 0 判準收進去:%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收②:不准重新引入 `#104` 的套件洩漏洞。
|
||||
// `node_modules` 底下即使巢狀著一個「整棵零程式碼」的文件目錄,也不能被收——
|
||||
// `toolOwnedDirNames` 的優先序排在 Phase 0 判準之前,整棵 `SkipDir`,
|
||||
// 新判準根本沒有機會看到 `node_modules` 底下的任何內容。
|
||||
func TestPlanIngest_Phase0不重新引入套件洩漏洞(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["node_modules/some-pkg/docs-backup/README.md"] = "# 別人的套件文件"
|
||||
files["node_modules/some-pkg/docs-backup/GUIDE.md"] = "# 別人的套件文件"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, _ := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "node_modules/") {
|
||||
t.Fatalf("Phase 0 判準洩漏了 node_modules 底下的內容(重新打開 #104 那個洞):%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Phase 0——反面驗收③:真正的程式碼目錄不受影響,仍走原本的 docs-only 通用跳過。
|
||||
// `workers/pms-auth` 自己直接放著 `.go`(與 `package.json`),零程式碼那一條不成立,
|
||||
// 整棵照舊被跳過——不因為新判準而多送出任何一份程式碼旁邊的檔案。
|
||||
func TestPlanIngest_Phase0真正的程式碼目錄不受影響(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
files := codeProjectFiles("", ".go", "package main")
|
||||
files["workers/pms-auth/package.json"] = `{"name":"pms-auth"}`
|
||||
files["workers/pms-auth/main.go"] = "package main"
|
||||
files["workers/pms-auth/README.md"] = "# 這個服務怎麼跑"
|
||||
writeFixture(t, root, files)
|
||||
|
||||
payload, plan := scanWithPlan(t, root)
|
||||
got := eventPaths(payload)
|
||||
t.Logf("策略=%s|送出:%v", plan.Mode, got)
|
||||
for _, p := range got {
|
||||
if strings.Contains(p, "workers/pms-auth/") {
|
||||
t.Fatalf("`workers/pms-auth` 自己直接放著 .go,不該被 Phase 0 判準收進去:%v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 接線:**模式選擇的判準只有一個地方**。
|
||||
//
|
||||
// 這一票的同款形狀出現過四次(能力做好了,卻不在會被執行的那條路上)。
|
||||
|
||||
+1
-1
@@ -274,7 +274,7 @@ func syncInventory(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dr
|
||||
// 只帶 library ⇒ 兩台機器上同名的資料夾會生出一模一樣的鍵,後同步的那台會把
|
||||
// 前一台的總覽卡蓋掉。這裡與逐檔卡走同一組欄位,不另開一種。
|
||||
mach := cfg.machineIdentity()
|
||||
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
|
||||
status, _, err := cfg.postJSON(stepInventory, cfg.triggerURL(wf), map[string]any{
|
||||
"page_name": page,
|
||||
"path": res.Path,
|
||||
"card_content": card,
|
||||
|
||||
+31
-2
@@ -16,17 +16,42 @@ import (
|
||||
|
||||
// splitInventory 把 results 拆成(總覽卡, 其餘)。既有測試斷言「幾個檔案事件」時
|
||||
// 用 rest;要驗結構先行本身時用 inv。
|
||||
// splitInventory 把「每輪都會做的結構回報」從「檔案事件」裡分出來。
|
||||
//
|
||||
// 有兩種結構回報,都不是檔案事件、都與 LLM 無關、都在額度閘之外:
|
||||
// - `inventory`=資料夾總覽**知識卡**(給檢索用的 markdown,走 rag_ingest_card)
|
||||
// - `folder_tree`=portal 畫面用的**結構化樹**(InkStoneCo#44,走 /portal/daemon/folder-tree)
|
||||
//
|
||||
// 🔴 2026-08-17:`folder_tree` 這一類是後加的,而本函式當時沒跟著加 ⇒ 五支既有測試
|
||||
// 突然把「樹的回報」當成一個檔案事件,紅了。**這正是「新增一種結果型別」時最容易漏的一環**:
|
||||
// 分類器只認得舊的那一種,新的就靜靜地落進 rest。以後再加第三種,記得改這裡。
|
||||
func splitInventory(results []DirectResult) (inv, rest []DirectResult) {
|
||||
for _, r := range results {
|
||||
if r.Type == "inventory" {
|
||||
switch r.Type {
|
||||
case "inventory":
|
||||
inv = append(inv, r)
|
||||
} else {
|
||||
case "folder_tree":
|
||||
// 樹不算總覽卡(回傳值只有兩個,刻意不擴成三個——呼叫端在意的是
|
||||
// 「哪些是檔案事件」,樹跟總覽卡一樣都屬於「不是檔案事件」那一堆)。
|
||||
// 要專門驗樹的測試自己從 results 撈 Type=="folder_tree"。
|
||||
default:
|
||||
rest = append(rest, r)
|
||||
}
|
||||
}
|
||||
return inv, rest
|
||||
}
|
||||
|
||||
// folderTreeResults 撈出本輪的資料夾樹回報(專門驗樹的測試用)。
|
||||
func folderTreeResults(results []DirectResult) []DirectResult {
|
||||
var out []DirectResult
|
||||
for _, r := range results {
|
||||
if r.Type == "folder_tree" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func invEntries(paths map[string]int64) map[string]*ManifestEntry {
|
||||
m := map[string]*ManifestEntry{}
|
||||
for p, mt := range paths {
|
||||
@@ -117,6 +142,10 @@ func TestDirectInventory_先於萃取且冪等(t *testing.T) {
|
||||
}
|
||||
var order []string // 按抵達順序記 page_name
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// InkStoneCo#44:資料夾樹不是卡片(沒有 page_name),不能算進「送了幾張卡」。
|
||||
if answeredFolderTreePost(w, r) {
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
|
||||
+19
-3
@@ -83,9 +83,25 @@ func UnmarkName(base string) string {
|
||||
// ⚠️ 例外只有這兩個目錄。監看根底下**其他任何位置**的新檔,一律得帶前綴——
|
||||
// 想在別處寫一個「名字不能改」的檔時,先想清楚那個檔為什麼不能住進這兩個目錄。
|
||||
func IsMachineOwnedRel(relSlash string) bool {
|
||||
if IsMarked(pathBase(relSlash)) {
|
||||
return true
|
||||
}
|
||||
return IsMarked(pathBase(relSlash)) || InMachineOwnedDir(relSlash)
|
||||
}
|
||||
|
||||
// InMachineOwnedDir 只回答上面那兩條路的**第二條**:這個路徑上有沒有一層是
|
||||
// `.arcrun-rag/` 或 `.wiki/`。
|
||||
//
|
||||
// 🔴 為什麼要把它單獨切出來(inkstone/Arcrun#180 ↔ #134,2026-08-28):
|
||||
// 兩條路的**證據強度不一樣**,而收檔判準需要分得開它們——
|
||||
//
|
||||
// 目錄(本函式)=**位置**證明它是產物。`.wiki/` 是這一版寫卡的唯一落點,
|
||||
// 任何時候走到那裡的東西都是我們此刻正在維護的產物 ⇒ 鐵證。
|
||||
// 前綴(IsMarked)=**歷史**說某一版的 daemon 寫過它。它不保證「現在還是產物」:
|
||||
// leo 的 `youlinhsieh-test1` 底下那 9 張 `cards/arcrun-*.md`
|
||||
// 是 8/4 舊版寫的,而他 08-14 明講「這些庫都早就萃好了⋯⋯直接 ingest」
|
||||
// ——對他而言那就是他的知識。
|
||||
//
|
||||
// ⇒ 要「絕不把產物當原稿」時用本函式;要「連歷史產物都不碰」時才用 IsMachineOwnedRel。
|
||||
// 規約本身(每個新檔都要帶標記)沒有變,vault_footprint_test.go 仍然用完整判準。
|
||||
func InMachineOwnedDir(relSlash string) bool {
|
||||
for _, seg := range strings.Split(relSlash, "/") {
|
||||
if seg == workspaceRelDir || seg == wikiRelDir {
|
||||
return true
|
||||
|
||||
@@ -89,6 +89,8 @@ func Run(args []string) int {
|
||||
return runTemplateInstall(args[1:])
|
||||
case "tidy":
|
||||
return runTidy(args[1:])
|
||||
case "cleanup":
|
||||
return runCleanup(args[1:])
|
||||
default:
|
||||
usage()
|
||||
return 2
|
||||
@@ -107,7 +109,9 @@ func usage() {
|
||||
collector sync --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
|
||||
collector direct --config <config.json> [--once] [--dry-run]
|
||||
collector tidy --folder <dir> [--apply] [--json]
|
||||
collector cleanup --folder <dir> [--keep <還在看守的別的資料夾>]… [--apply] [--json]
|
||||
tidy: 找出以前寫進資料夾、還沒帶 arcrun- 標記的舊產物並改名/搬走(預設只列出不動手,加 --apply 才執行;從不刪檔)
|
||||
cleanup: 斷連時把 daemon 建立的 .arcrun-rag/ 與各層 .wiki/ 收掉(預設只列清單,加 --apply 才真的刪;認不出來的一律留著)
|
||||
upload 需環境變數: CF_ACCOUNT_ID / CF_API_TOKEN / R2_BUCKET
|
||||
sync 另需: ARCRUN_TRIGGER_URL(named-webhook 觸發完整 URL)
|
||||
direct(無 R2): 監看資料夾 → 讀檔內容直送實例 rag_ingest_direct workflow;設定走 --config JSON`)
|
||||
|
||||
+69
@@ -56,6 +56,27 @@ type ManifestEntry struct {
|
||||
// 真因(Cloudflare「當日免費額度用完」/「這份 PDF 沒有文字層」)**當場消失**
|
||||
// ⇒ 使用者以為是我們壞掉。原因必須跟著 entry 存活到下次成功為止。
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
|
||||
// ── 雲端對帳(`inkstone/arcrun-rag#140`,2026-08-26)──────────────────────
|
||||
// 病:上面那個 IngestedHash 的章**永遠不會過期**。雲端 08-14 被重裝/清空之後,
|
||||
// 檔案內容沒變 ⇒ 不產生事件 ⇒ 永遠不會重送,而且沒有任何地方會說話。
|
||||
// ⇒ 章的意思要從「我送過了」改成「我送過了**而且雲端現在還有**」,
|
||||
// 後半句只能去問雲端(見 cloud_audit.go)。這三個欄位是那個問答的記帳。
|
||||
//
|
||||
// CloudCheckedAt=最後一次向雲端確認過這個檔的時間(unix 秒)。
|
||||
// 用途有二:①決定「太久沒對帳」該重問(cloudAuditRecheckInterval);
|
||||
// ②讓候選排序把最久沒對的排前面 ⇒ 巨量資料夾也會被輪完,不會有人永遠排不到。
|
||||
CloudCheckedAt int64 `json:"cloud_checked_at,omitempty"`
|
||||
// CloudMissingAt=最後一次「雲端查不到、章被拔掉」的時間(unix 秒,0=從沒發生)。
|
||||
// 🔴 它同時是**防重送迴圈的閘**:雲端 ingest 非同步,剛送出去那幾秒查不到是正常的,
|
||||
// 沒有這個窗口,對帳下一輪又判它不在 ⇒ 每輪重送 ⇒ 把使用者的 AI 額度燒光
|
||||
// ⇒ 那就是「把一個 bug 換成另一個」(#140 驗收條件 5 明文禁止)。
|
||||
CloudMissingAt int64 `json:"cloud_missing_at,omitempty"`
|
||||
// NoCloudCard=這一次 ingest 蓋章時**沒有任何卡片被送上雲**
|
||||
//(萃取判定「無可萃取概念」,cards 為空 ⇒ direct.go 的送卡迴圈一圈都沒跑)。
|
||||
// 🔴 不記這一格的話,對帳每天都會查到「雲端沒有它」⇒ 每天重萃一次、永遠停不下來。
|
||||
// 舊的 manifest 沒有這一格=當成「送過」,那正是本票要救的那批檔的處境。
|
||||
NoCloudCard bool `json:"no_cloud_card,omitempty"`
|
||||
}
|
||||
|
||||
// retryBackoff 退避階梯:1m → 5m → 15m → 1h → 6h,之後每次 6h。
|
||||
@@ -86,6 +107,13 @@ type Manifest struct {
|
||||
// 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。
|
||||
InventoryFailHash string `json:"inventory_fail_hash,omitempty"`
|
||||
InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"`
|
||||
// SourceOriginRepairedAt=這個監看根的「### 出處」已全部改成
|
||||
// 「機器 › 知識庫 › 庫內路徑」的時間(unix 秒,0=還沒修過)。
|
||||
// 🔴 `inkstone/Arcrun#167`:舊卡寫的是 `../<檔名>`(daemon 的內部目錄結構),
|
||||
// AI 照著答,使用者走不到那個檔。修產生端只治得了新卡,既有的要就地重推
|
||||
// (見 sourcerepair.go)。這一格的存在是為了**修完就不再每輪掃全庫**——
|
||||
// 沒有它,這個修復會變成一個永遠在跑的背景工作。
|
||||
SourceOriginRepairedAt int64 `json:"source_origin_repaired_at,omitempty"`
|
||||
|
||||
// PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單
|
||||
// (key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩:
|
||||
@@ -101,6 +129,26 @@ type Manifest struct {
|
||||
// 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它,
|
||||
// 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。
|
||||
PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"`
|
||||
|
||||
// CloudAuditAt=這個資料夾最後一次跑雲端對帳批次的時間(unix 秒)。
|
||||
// 純節流用(cloudAuditFolderInterval):daemon 預設 5 秒一輪,沒有這道閘
|
||||
// 就是每 5 秒對雲端發 20 個請求。與 InventoryHash 同為 Manifest 層欄位,
|
||||
// Scan() 的 rebuild 只重建 Entries,天然跨輪存活。
|
||||
CloudAuditAt int64 `json:"cloud_audit_at,omitempty"`
|
||||
// ── InkStoneCo#44 線 A:資料夾樹的冪等記帳(見 foldertree.go)───────────────
|
||||
// FolderTreeHash=最後一次**成功送達雲端**的整棵樹內容雜湊;
|
||||
// FolderTreeFailHash/FolderTreeNextRetry=上一次送失敗的內容與退避到期時間。
|
||||
// 存在理由與上面 InventoryHash 那三個完全同款(t195:沒有退避,雲端一壞就是每 5 秒撞一次)。
|
||||
// 同屬 Manifest 層欄位 ⇒ Scan() 的 rebuild 只重建 Entries,天然跨輪存活。
|
||||
// FolderTreeNextSend=**成功送出後**的最小間隔到期時間(folderTreeMinInterval)。
|
||||
// 與上面兩個 *Retry 欄位是不同的東西:那兩個是「失敗退避」(t195 的慣例),
|
||||
// 這個是「成功也要節流」。需要它的原因是這棵樹沒有「本輪無事件就不送」那道閘
|
||||
//(空資料夾一個事件都沒有)⇒ 初次同步時內容雜湊每輪都變,沒有它就是每 5 秒
|
||||
// 一次雲端 KV 寫入,一小時燒光當天額度。
|
||||
FolderTreeHash string `json:"folder_tree_hash,omitempty"`
|
||||
FolderTreeFailHash string `json:"folder_tree_fail_hash,omitempty"`
|
||||
FolderTreeNextRetry int64 `json:"folder_tree_next_retry,omitempty"`
|
||||
FolderTreeNextSend int64 `json:"folder_tree_next_send,omitempty"`
|
||||
}
|
||||
|
||||
// QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。
|
||||
@@ -186,6 +234,27 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
|
||||
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
|
||||
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
|
||||
e.LastError = ""
|
||||
// #140:預設「這次有送卡上雲」;真的一張卡都沒送的那條路由呼叫端補打
|
||||
// MarkNoCloudCard(見 direct.go 的 cards 為空分支)。預設值放這裡而不是
|
||||
// 讓呼叫端每次都設,是因為漏設的方向要落在**安全的那一邊**:
|
||||
// 誤標 false ⇒ 頂多多對一次帳(一個唯讀請求);誤標 true ⇒ 這個檔從此
|
||||
// 不再被對帳,雲端掉了也沒人發現——那正是本票在修的病。
|
||||
e.NoCloudCard = false
|
||||
// 🔴 CloudCheckedAt/CloudMissingAt **刻意不清**:
|
||||
// 前者是輪值排序的依據,後者是防重送迴圈的 grace 窗口與「補送過」的證據
|
||||
// (ResyncSummary 靠 IngestedAt >= CloudMissingAt 判斷這份是不是剛補回來的)。
|
||||
// 清掉等於把剛補送成功的那批檔立刻放回可再拔章的池子。
|
||||
return true
|
||||
}
|
||||
|
||||
// MarkNoCloudCard 標記「這次 ingest 沒有任何卡片被送上雲端」。
|
||||
// 只有萃取判定「無可萃取概念」(cards 為空)的那條路會呼叫。見 NoCloudCard 欄位註解。
|
||||
func (m *Manifest) MarkNoCloudCard(path string) bool {
|
||||
e, ok := m.Entries[path]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e.NoCloudCard = true
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+39
-8
@@ -23,6 +23,7 @@ package collector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -31,6 +32,10 @@ import (
|
||||
)
|
||||
|
||||
// probeHTTP 探測用短逾時:只是要知道 route 在不在,不等 LLM 生成。
|
||||
//
|
||||
// 🔴 `inkstone/arcrun-rag#153`:這把 20 秒是保險,真正的上限由呼叫端帶進來的
|
||||
// context 決定。探測是**每輪、每個帳號的第一發**——它撞到不回應的端點時,
|
||||
// 後面所有帳號都還沒開始,所以它也是最早能認出「這個帳號今天不回應」的位置。
|
||||
var probeHTTP = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// CloudAIState 是「某個雲端實例的 Workers AI 萃取能力」探測結果。
|
||||
@@ -47,25 +52,27 @@ type CloudAIState struct {
|
||||
// ⇒ 算通。這樣探測不會真的燒 LLM 額度,也不會產生垃圾卡片。
|
||||
// - 401 代表 route 在、只是金鑰不對 ⇒ route 存在,同樣算「雲端有這功能」,
|
||||
// 金鑰問題由既有的連線流程去報,不混在這裡講。
|
||||
func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
|
||||
func ProbeWorkersAI(ctx context.Context, cypherURL, apiKey string) (CloudAIState, error) {
|
||||
base := strings.TrimSuffix(strings.TrimSpace(cypherURL), "/")
|
||||
if base == "" {
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}
|
||||
return CloudAIState{Ready: false, Note: "還沒連上知識庫"}, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"page_name": "", "text": ""})
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/portal/daemon/extract", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}
|
||||
return CloudAIState{Ready: false, Note: "雲端 AI 狀態查不到"}, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Arcrun-API-Key", strings.TrimSpace(apiKey))
|
||||
|
||||
resp, err := probeHTTP.Do(req)
|
||||
if err != nil {
|
||||
// #153:錯誤原件一起回去,呼叫端才分得出「等到超時」與「立刻連不上」——
|
||||
// 前者要記進斷路器,後者不該(不然一次斷網就把帳號判成停機)。
|
||||
// 連不上(離線/網路問題)≠ 雲端沒裝。講「查不到」而不是「還沒通」,
|
||||
// 免得把網路問題誤報成「你沒更新」讓用戶白跑一趟。
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}
|
||||
return CloudAIState{Ready: false, Note: "連不上你的知識庫,雲端 AI 狀態查不到"}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -74,14 +81,38 @@ func ProbeWorkersAI(cypherURL, apiKey string) CloudAIState {
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: "雲端 AI 還沒通 ⇒ 你的知識庫是舊版,請到 portal 按「立即更新」重裝一次",
|
||||
}
|
||||
}, nil
|
||||
case resp.StatusCode < 500:
|
||||
// 200/400/401… 都代表這條 route 存在=雲端有這個功能。
|
||||
return CloudAIState{Ready: true}
|
||||
return CloudAIState{Ready: true}, nil
|
||||
default:
|
||||
return CloudAIState{
|
||||
Ready: false,
|
||||
Note: fmt.Sprintf("雲端 AI 暫時有狀況(HTTP %d),稍後會自動再試", resp.StatusCode),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// probeWorkersAI=帶「等待閘」的探測(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 為什麼這一發特別重要:它是每輪、每個帳號的**第一發**。撞到不回應的端點時,
|
||||
// 後面的資料夾一件都還沒開始 ⇒ 這裡認出來,整個帳號的其餘工作就都省下來了。
|
||||
// 反過來說,漏掉這一發的話,前面所有的閘都白裝——2026-08-28 第一版就是這樣:
|
||||
// 斷路器跳了,可是一輪還是要 20 秒,因為那 20 秒全花在這一發上。
|
||||
func (c *DirectConfig) probeWorkersAI() CloudAIState {
|
||||
if note := c.unreachableNote(); note != "" {
|
||||
return CloudAIState{Ready: false, Note: note}
|
||||
}
|
||||
gate := c.openGate(stepProbeAI)
|
||||
state, err := ProbeWorkersAI(gate.ctx, c.CypherURL, c.APIKey)
|
||||
gate.release()
|
||||
if err == nil {
|
||||
gate.ok()
|
||||
}
|
||||
if perr := gate.record(err); perr != nil {
|
||||
// 等到超時 ⇒ 講的是「沒有回應」,不是「你的雲端沒裝好」。
|
||||
// 把等待誤報成「你沒更新」會害使用者白跑一趟去按重裝。
|
||||
return CloudAIState{Ready: false, Note: perr.Error()}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
+16
@@ -37,6 +37,16 @@ type SyncProgress struct {
|
||||
Pending int `json:"pending"` // 還沒送,會自動接著做(含退避等待中的)
|
||||
Stuck int `json:"stuck"` // 連續失敗達上限、已暫停自動重試——**不會自己好**
|
||||
Unreadable int `json:"unreadable"` // 格式讀不了,根本沒進 manifest(由呼叫端補進來)
|
||||
// Failing=Pending 裡「最近一次已經送失敗過、正在退避等重試」的份數
|
||||
// (`inkstone/arcrun-rag#159`)。**它是 Pending 的子集合,不進不變式**——
|
||||
// 加總永遠只算 Total == Done+Pending+Stuck+Unreadable。
|
||||
//
|
||||
// 🔴 為什麼要分出來:Pending 同時裝著兩種完全不同的處境——「還沒輪到」與
|
||||
// 「一直送不上去」。混在一起時畫面只能講「同步中」,而 2026-08-28 leo 桌面上
|
||||
// geek6688-test1 的 14 份**每一份都在失敗重試**(雲端回 HTTP 500 Too many
|
||||
// subrequests,fail_count 是 7 與 3),講「同步中」等於叫他等一件正在壞掉的事。
|
||||
// 分出來,畫面才判斷得出該不該示警(見 arcrun-app/folder_badge.go)。
|
||||
Failing int `json:"failing"`
|
||||
}
|
||||
|
||||
// Add 把另一個資料夾/帳號的進度累加進來——首頁與診斷檔講的都是總量。
|
||||
@@ -47,6 +57,7 @@ func (p SyncProgress) Add(o SyncProgress) SyncProgress {
|
||||
Pending: p.Pending + o.Pending,
|
||||
Stuck: p.Stuck + o.Stuck,
|
||||
Unreadable: p.Unreadable + o.Unreadable,
|
||||
Failing: p.Failing + o.Failing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +80,11 @@ func (m *Manifest) Progress() SyncProgress {
|
||||
p.Stuck++
|
||||
default:
|
||||
p.Pending++
|
||||
if e.FailCount > 0 {
|
||||
// #159:還在排隊,但**已經失敗過**——退避等下一次重試。
|
||||
// 與 Pending 並存(子集合),不影響上面的不變式。
|
||||
p.Failing++
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -139,6 +139,35 @@ type TriggerPayload struct {
|
||||
// 而畫面上的數字是 **0**。講一個 0 跟安靜地少收,對使用者是同一件事。
|
||||
ExcludedDirs []ExcludedDir `json:"-"` // 已排序,上限 MaxExcludedDirsListed
|
||||
ExcludedDirCount int `json:"-"` // 總數(可能大於清單長度)
|
||||
|
||||
// ── InkStoneCo#44 線 A:portal 的資料夾樹(leo 2026-08-17)─────────────────
|
||||
// DirStats=走訪時**逐目錄**數出來的分母(key=相對監看根的路徑,`""`=根)。
|
||||
// AllExcludedDirs=整棵被剪掉的目錄,**未裁切**(上面那個 ExcludedDirs 為了畫面
|
||||
// 只留 20 筆;樹要畫得完整需要全量,不然「哪些沒收」會缺角)。
|
||||
//
|
||||
// 🔴 為什麼分母一定要在這裡數、不能另外走一趟:判斷「支不支援/收不收」的那一整套
|
||||
// 判準就活在這個 WalkDir 裡(allowedExt/docLikeExt/Plan.KeepsFile/TemplateOwns)。
|
||||
// 另外走一趟=同一件事第二份實作,必然漂移 ⇒ 畫面上的數字與實際收的檔對不起來,
|
||||
// 而 leo 要的正是「兩個數字的差額解釋得了」。
|
||||
// 同 `json:"-"`:給本機與 portal 用的,不進 collector-trigger schema。
|
||||
DirStats map[string]*dirStat `json:"-"`
|
||||
AllExcludedDirs []ExcludedDir `json:"-"`
|
||||
}
|
||||
|
||||
// dirStat=一個目錄「這一層直接放的檔案」的分類計數(不含子目錄;子樹合計由畫面自己疊,
|
||||
// 存兩套就是同一件事兩份實作,遲早對不起來)。
|
||||
//
|
||||
// 🔴 不變式:`total == supported + unsupported + excluded`。
|
||||
// 少一類就會出現「兩個數字的差額解釋不了」,而那正是 leo 這條規格要解掉的病。
|
||||
type dirStat struct {
|
||||
total int // 這一層看得到的檔案總數(分母)
|
||||
supported int // 通過所有閘、進了 manifest 的(=分子的候選)
|
||||
unsupported int // 副檔名還讀不了的(docLikeExt 與其他)
|
||||
excluded int // 收檔策略/範本身分決定不收的(程式碼多半落這裡)
|
||||
// excludeWhy=這一層的檔被排除的理由(產品文案,第一個排除的檔說了算)。
|
||||
// #180:以前不收就是「不走進去」,理由掛在被剪掉的目錄上;現在走進去了,
|
||||
// 理由得跟著檔案的排除一起記下來,不然畫面只剩一個沒有解釋的 `0/6`。
|
||||
excludeWhy string
|
||||
}
|
||||
|
||||
// MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount
|
||||
@@ -304,6 +333,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數
|
||||
// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
|
||||
var excludedDirs []ExcludedDir
|
||||
// InkStoneCo#44 線 A:逐目錄的分母。與上面幾個計數器同一趟走訪算出來——
|
||||
// 判準只有這一份,畫面上的數字才可能與實際收的檔對得起來。
|
||||
dirStats := map[string]*dirStat{"": {}} // 根一定存在(空資料夾也要有節點,arcrun-rag#106)
|
||||
statOf := func(relSlash string) *dirStat {
|
||||
dir := folderOfRel(relSlash)
|
||||
st, ok := dirStats[dir]
|
||||
if !ok {
|
||||
st = &dirStat{}
|
||||
dirStats[dir] = st
|
||||
}
|
||||
return st
|
||||
}
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
@@ -323,16 +364,35 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
if p != root && opts.SkipDirNames[name] {
|
||||
return filepath.SkipDir // 名單目錄(呼叫端自訂)整棵跳過
|
||||
}
|
||||
// #104:依策略整棵跳過(使用者的 .gitignore/依賴/建置產物/範本/
|
||||
// worktree/巢狀 repo/以及非本次策略要收的區域)。
|
||||
// #104:依策略整棵剪掉(使用者的 .gitignore/依賴/建置產物/
|
||||
// worktree/巢狀 repo)。
|
||||
// 🔴 每一次剪枝都要留下「哪一個、為什麼」——票上的紅線是排除規則要看得見,
|
||||
// 而 2026-08-16 實測發現原本整棵剪掉的部分完全沒有被記錄。
|
||||
//
|
||||
// 🔴 inkstone/Arcrun#180(leo 2026-08-28):**「不收」不准再實作成「不走進去」。**
|
||||
// leo 原話:「我要的就是雲端看到**所有的 folder** 像 tree 一樣呈現,去遍歷就好了」
|
||||
// 「所有的 system-dev 都可以展開,**因為就算沒有可萃的它也有下層**」。
|
||||
// 實測差距:`youlinhsieh-test1` 地端 `find -type d` 有 13 個目錄,
|
||||
// 雲端的樹只有 3 個節點——樹在 `system-dev` 那一節就斷了。
|
||||
// ⇒ 收檔範圍那幾條(curated-wiki 只走 wiki/docs-only 只走文件區/
|
||||
// 範本目錄)已從 SkipsDirWhy 搬到 CollectsDirWhy:**走訪照走、節點照生、
|
||||
// 數字照算**,只是標明「這一層我不收」。
|
||||
if p != root {
|
||||
if rel, ok := relOf(); ok {
|
||||
if skip, why := opts.Plan.SkipsDirWhy(rel, p); skip {
|
||||
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// #44 線 A:走得進去的目錄一律登記,**就算它一個檔都沒有**
|
||||
// ——leo 的規格是「不管那層有沒有文件,整棵樹都要採」。
|
||||
if _, ok := dirStats[rel]; !ok {
|
||||
dirStats[rel] = &dirStat{}
|
||||
}
|
||||
// #180:走進來了,但這一層的檔這次不收 ⇒ 節點照生、子節點照長,
|
||||
// 只是把理由掛上去(畫面仍然講得出「為什麼這裡沒收」)。
|
||||
if collect, why := opts.Plan.CollectsDirWhy(rel); !collect {
|
||||
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -342,6 +402,19 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
}
|
||||
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
|
||||
excludedByPlan++
|
||||
// #44 線 A:這是「使用者看得到、但我們決定不收」的檔(程式碼多半落在這裡)。
|
||||
// 它**要算進分母**——leo 的規格就是「兩個數字不相等是正常的,差額=不支援」,
|
||||
// 而分母裡沒有它,差額就永遠解釋不了。
|
||||
s := statOf(rel)
|
||||
s.total++
|
||||
s.excluded++
|
||||
if s.excludeWhy == "" {
|
||||
if _, why := opts.Plan.CollectsDirWhy(folderOfRel(rel)); why != "" {
|
||||
s.excludeWhy = why
|
||||
} else {
|
||||
s.excludeWhy = "這一層的檔案不在這次的收檔範圍裡"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
|
||||
@@ -358,13 +431,42 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// · 我們代裝 template 的資料夾(沒有 `.git`)⇒ 那些檔是**我們鋪的**,照舊不收
|
||||
// · 使用者自己的 repo(有 `.git`)⇒ 那份 wiki 是**他寫的**,正是他要我們讀的
|
||||
// 判準只有 PlanIngest 一個地方,見 ingestplan.go。
|
||||
//
|
||||
// 🔴 inkstone/Arcrun#180:**不收照舊,但不准再從畫面上消失。**
|
||||
// leo 要的是「知道資料夾下有幾個檔案被萃」——以前這裡直接 `return nil`,
|
||||
// 那些檔連分母都沒進去,於是 `system-dev/wiki`(實際 6 個檔)在樹上是 `0/0`、
|
||||
// `scripts`(實際 1 個檔)也是 `0/0`。**兩個 0 都是我們自己編的數字。**
|
||||
// 現在照樣不收,但算進 total/excluded,使用者看得到「這裡有 6 個,我一個都沒收」。
|
||||
//
|
||||
// 🔴 inkstone/Arcrun#180 驗收條件 2/3:判準從**路徑前綴**換成**內容**
|
||||
// (templateuntouched.go)。舊的 `TemplateOwns` 把 `system-dev/` 整棵當範本,
|
||||
// 分不出同一棵樹底下的兩種東西——`system-dev/wiki/status.md`(安裝器鋪的空殼)
|
||||
// 與 `system-dev/wiki/cards/*.md`(9 張真的卡)於是一起被丟掉。
|
||||
// 現在逐檔比對:和快照逐字相同=空殼,不收;其餘照常判斷。
|
||||
if rel, rerr := filepath.Rel(root, p); rerr == nil {
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if TemplateOwns(relSlash) && !opts.Plan.OverridesTemplateOwned(relSlash) {
|
||||
if TemplateUntouched(relSlash, p) {
|
||||
s := statOf(relSlash)
|
||||
s.total++
|
||||
s.excluded++
|
||||
if s.excludeWhy == "" {
|
||||
s.excludeWhy = "這是安裝範本時鋪下來的空白樣板,你還沒在上面寫過東西"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
// #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔(隱藏檔與 SkipPaths 的
|
||||
// 機器檔刻意不算進分母:**我們自己寫的東西不該佔用他的注意力**)。
|
||||
if rel, ok := relOf(); ok {
|
||||
s := statOf(rel)
|
||||
s.total++
|
||||
if allowedExt[ext] {
|
||||
s.supported++
|
||||
} else {
|
||||
s.unsupported++
|
||||
}
|
||||
}
|
||||
if !allowedExt[ext] {
|
||||
// G-6.2:**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
|
||||
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
|
||||
@@ -556,6 +658,15 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。
|
||||
// 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。
|
||||
ne.LastError = carry.LastError
|
||||
// 🔴 #140(2026-08-26):雲端對帳的三個欄位同樣要 carry,而且漏了會**很貴**:
|
||||
// - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批(請求風暴)
|
||||
// - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章
|
||||
// → 再補送…把使用者的 AI 額度燒光(票上明文禁止的「把一個 bug 換成另一個」)
|
||||
// - NoCloudCard 歸零 ⇒ 沒卡可送的檔每天被重萃一次,永遠停不下來
|
||||
// 這正是上面那句警告(t195/LastError 5fcc139)的第三次實例,所以照著它走。
|
||||
ne.CloudCheckedAt = carry.CloudCheckedAt
|
||||
ne.CloudMissingAt = carry.CloudMissingAt
|
||||
ne.NoCloudCard = carry.NoCloudCard
|
||||
}
|
||||
newEntries[p] = ne
|
||||
}
|
||||
@@ -574,6 +685,10 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
// 排序=畫面每輪穩定;裁切前先記總數,不然「等 N 個」會少報。
|
||||
sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path })
|
||||
excludedDirCount := len(excludedDirs)
|
||||
// #44 線 A:樹要畫得完整,需要**未裁切**的全量(下面那個裁切是給狀態列用的)。
|
||||
// 先複製再裁切——共用同一個底層陣列的話,裁切會讓樹跟著少一截。
|
||||
allExcludedDirs := make([]ExcludedDir, len(excludedDirs))
|
||||
copy(allExcludedDirs, excludedDirs)
|
||||
if len(excludedDirs) > MaxExcludedDirsListed {
|
||||
excludedDirs = excludedDirs[:MaxExcludedDirsListed]
|
||||
}
|
||||
@@ -592,5 +707,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
|
||||
ExcludedByPlan: excludedByPlan,
|
||||
ExcludedDirs: excludedDirs,
|
||||
ExcludedDirCount: excludedDirCount,
|
||||
DirStats: dirStats,
|
||||
AllExcludedDirs: allExcludedDirs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// sourcerepair.go — 既有卡片的「### 出處」就地修正(`inkstone/Arcrun#167`)。
|
||||
//
|
||||
// 病(leo 2026-08-27 用 n8n 實測抓到):卡片內文的「### 出處」寫的是 `../<檔名>`,
|
||||
// 那是**卡片檔相對於原檔**的內部目錄結構。它被當成知識內容存進 KBDB,於是任何
|
||||
// 接上 MCP 的 AI 問「原文在哪」都照著答——而使用者拿著 `../小果被AFTEE詐貸.pdf`
|
||||
// 走不到任何地方。原稿在子資料夾時更慘:資料夾整段被弄丟。
|
||||
//
|
||||
// 修在產生端(wikishape.go 的 renderSourceLine)只治得了**新卡**。
|
||||
// 票上寫死了「不能只修新的」⇒ 這支檔是對**既有資料**的處置。
|
||||
//
|
||||
// 為什麼是「就地修 + 重推」而不是「重萃」(三選一的取捨):
|
||||
//
|
||||
// 重萃 每份檔要再燒一次 AI 額度,而且模型每次判斷不同 ⇒ 使用者眼前的卡會無故變樣。
|
||||
// 出處那一塊**根本不是模型寫的**(它是機械組裝的),為它重萃是白花錢。
|
||||
// 留著 票上明文禁止。
|
||||
// 就地修 ✅ 卡片檔在本機還在 ⇒ 重畫那一塊、原樣重推。零 LLM、內容其餘一字不動。
|
||||
//
|
||||
// 重推為什麼是安全的:`rag_ingest_card` 進門先刪同 page_name+同 source_path 的舊
|
||||
// blocks/triplets 再寫(見該 workflow 的 list_old_blocks/pick_stale)⇒ 同一張卡
|
||||
// 推第二次是取代,不是疊加。
|
||||
//
|
||||
// 三條自我約束:
|
||||
// - **一次只修一批**(sourceRepairBatch):巨量資料夾不該一輪湧完。
|
||||
// - **修完蓋章就不再跑**(Manifest.SourceOriginRepairedAt):不是每輪掃一次全庫。
|
||||
// - **推失敗不蓋章**:下一輪繼續,但也不重推已經成功的(章是逐檔記在 wiki manifest 上)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRepairBatch=單輪最多修幾份文件(含重推)。
|
||||
// 與 cloudAuditBatch 同一個精神:積壓再多也不該一輪湧完。
|
||||
const sourceRepairBatch = 20
|
||||
|
||||
// legacySourceMark=舊形出處的指紋。只認這一個字面,認不到就不動它
|
||||
//(已經是新形的、或使用者自己手改過的,一律不碰)。
|
||||
const legacySourceMark = "- `../"
|
||||
|
||||
// sourceHeading=出處那一塊的標題行。
|
||||
const sourceHeading = "### 出處"
|
||||
|
||||
type sourceRepairResult struct {
|
||||
Scanned int // 這一輪看過幾份文件
|
||||
Rewrote int // 本機卡片被重畫的份數
|
||||
Repushed int // 成功重推上雲的份數
|
||||
Err string // 最後一個錯誤(原文照留,leo 2026-08-06 的原則)
|
||||
Done bool // 這個監看根已經全部處理完
|
||||
}
|
||||
|
||||
// hasLegacySourceLine 回答「這張卡的出處還是舊形嗎」。
|
||||
func hasLegacySourceLine(card string) bool {
|
||||
i := strings.Index(card, sourceHeading)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(card[i:], legacySourceMark)
|
||||
}
|
||||
|
||||
// rewriteSourceBlock 把卡片裡的「### 出處」整塊換成新形。
|
||||
// 找不到那一塊=不是本塑形層產的卡,原樣回傳(不亂加東西)。
|
||||
func rewriteSourceBlock(card string, o SourceOrigin, cardName string) string {
|
||||
i := strings.Index(card, sourceHeading)
|
||||
if i < 0 {
|
||||
return card
|
||||
}
|
||||
rest := card[i+len(sourceHeading):]
|
||||
// 區塊結束=下一個標題行或檔尾。出處是「## 關聯」的最後一個 H3,
|
||||
// 實務上就是檔尾,但仍照界線處理(之後有人在它後面加段落也不會被吃掉)。
|
||||
end := len(card)
|
||||
for off := 0; ; {
|
||||
nl := strings.Index(rest[off:], "\n")
|
||||
if nl < 0 {
|
||||
break
|
||||
}
|
||||
off += nl + 1
|
||||
line := rest[off:]
|
||||
if j := strings.Index(line, "\n"); j >= 0 {
|
||||
line = line[:j]
|
||||
}
|
||||
if strings.HasPrefix(line, "#") {
|
||||
end = i + len(sourceHeading) + off
|
||||
break
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
renderSourceLine(&b, o, cardName)
|
||||
return card[:i] + b.String() + card[end:]
|
||||
}
|
||||
|
||||
// cardNameOfFile 從卡片相對路徑取卡名(=檔名去副檔名;BuildWikiDoc 的落點規則)。
|
||||
func cardNameOfFile(rel string) string {
|
||||
return strings.TrimSuffix(path.Base(filepath.ToSlash(rel)), ".md")
|
||||
}
|
||||
|
||||
// repairCardSourceBlocks 掃一個監看根的 wiki manifest,把還是舊形出處的卡就地修好,
|
||||
// 並把**文件卡**(唯一上雲的那張)原樣重推一次。
|
||||
//
|
||||
// dryRun 時只看不改(與整條 direct 路徑的 --dry-run 語意一致)。
|
||||
// force=不看「本機卡是不是還是舊形」,一律重推一次。
|
||||
// 這是給**手動補救**用的(雲端被清空過、或某一輪推到一半被中斷 ⇒ 本機新形、雲端舊形
|
||||
// ⇒ 常規判準會判「不用修」而永遠不補推)。daemon 常規路徑一律 false。
|
||||
func repairCardSourceBlocks(cfg *DirectConfig, absRoot string, m *Manifest, dryRun, force bool, now time.Time) *sourceRepairResult {
|
||||
if m == nil || (!force && m.SourceOriginRepairedAt > 0) {
|
||||
return nil // 這個根已經修完,不再每輪掃
|
||||
}
|
||||
wm := loadWikiManifest(absRoot)
|
||||
if len(wm.Docs) == 0 {
|
||||
if !dryRun {
|
||||
m.SourceOriginRepairedAt = now.Unix() // 沒有 .wiki 產物=沒東西可修,蓋章收工
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mach := cfg.machineIdentity()
|
||||
library := cfg.libraryFor(absRoot)
|
||||
|
||||
res := &sourceRepairResult{}
|
||||
remaining := 0
|
||||
for di := range wm.Docs {
|
||||
d := &wm.Docs[di]
|
||||
if len(d.Cards) == 0 {
|
||||
continue
|
||||
}
|
||||
libPath := path.Join(nodeFromKey(d.Node), filepath.ToSlash(d.Path))
|
||||
// 這一份的卡有沒有舊形出處?先看文件卡(第一張),它就是上雲的那張。
|
||||
docAbs := filepath.Join(absRoot, filepath.FromSlash(d.Cards[0]))
|
||||
body, rerr := os.ReadFile(docAbs)
|
||||
if rerr != nil {
|
||||
continue // 卡不見了(使用者刪過/被搬走)=不是這支檔的題目
|
||||
}
|
||||
// 🔴 判準只看**文件卡**(=唯一上雲的那張),而且它是「已修好**且已推上去**」的
|
||||
// 唯一憑據。實撞(2026-08-27,第一輪跑到一半被中斷):先改本機再推,被中斷之後
|
||||
// 本機已是新形、雲端還是舊的 ⇒ 下一輪看本機判「不用修」⇒ **那份永遠不會補推**。
|
||||
// 所以順序是**先推、推成功才寫本機**——推失敗的話本機留著舊形,下一輪自然重試。
|
||||
if !force && !hasLegacySourceLine(string(body)) {
|
||||
// 文件卡已是新形(代表也推過了);概念卡只落本機、不上雲,順手補齊即可。
|
||||
for _, c := range d.Cards[1:] {
|
||||
abs := filepath.Join(absRoot, filepath.FromSlash(c))
|
||||
raw, e := os.ReadFile(abs)
|
||||
if e != nil || !hasLegacySourceLine(string(raw)) {
|
||||
continue
|
||||
}
|
||||
fixed := rewriteSourceBlock(string(raw), SourceOrigin{
|
||||
MachineLabel: mach.Label, Library: library, LibraryPath: libPath,
|
||||
}, cardNameOfFile(c))
|
||||
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
|
||||
res.Err = werr.Error()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
res.Scanned++
|
||||
if res.Scanned > sourceRepairBatch {
|
||||
remaining++
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
continue
|
||||
}
|
||||
origin := SourceOrigin{MachineLabel: mach.Label, Library: library, LibraryPath: libPath}
|
||||
docCard := rewriteSourceBlock(string(body), origin, cardNameOfFile(d.Cards[0]))
|
||||
|
||||
// ① 先重推(零 LLM)。欄位與 direct.go 送新卡時逐欄一致,
|
||||
// workflow 進門會先刪同 page_name+同 source_path 的舊 blocks ⇒ 取代不疊加。
|
||||
status, _, perr := cfg.postJSON(stepRepairOrigin, cfg.triggerURL(cfg.CardIngestWF), map[string]any{
|
||||
"page_name": pageNameOf(libPath),
|
||||
"path": libPath,
|
||||
"card_content": docCard,
|
||||
"library": library,
|
||||
"machine": mach.ID,
|
||||
"machine_label": mach.Label,
|
||||
})
|
||||
if perr != nil || status < 200 || status >= 300 {
|
||||
if perr != nil {
|
||||
res.Err = perr.Error()
|
||||
} else {
|
||||
res.Err = "重推知識庫失敗(HTTP " + itoa(status) + ")"
|
||||
}
|
||||
remaining++ // 這一份沒推成功 ⇒ 本機不動(留著舊形當重試訊號)、這個根不能蓋章
|
||||
continue
|
||||
}
|
||||
|
||||
// ② 推成功了才寫本機(文件卡+概念卡)。
|
||||
for _, c := range d.Cards {
|
||||
abs := filepath.Join(absRoot, filepath.FromSlash(c))
|
||||
raw, e := os.ReadFile(abs)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
fixed := rewriteSourceBlock(string(raw), origin, cardNameOfFile(c))
|
||||
if fixed == string(raw) {
|
||||
continue
|
||||
}
|
||||
if werr := writeWikiFile(absRoot, abs, []byte(fixed)); werr != nil {
|
||||
res.Err = werr.Error()
|
||||
}
|
||||
}
|
||||
res.Rewrote++
|
||||
res.Repushed++
|
||||
}
|
||||
if !dryRun && remaining == 0 {
|
||||
m.SourceOriginRepairedAt = now.Unix()
|
||||
res.Done = true
|
||||
}
|
||||
if res.Scanned == 0 && res.Rewrote == 0 {
|
||||
return nil
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package collector
|
||||
|
||||
// sourcerepair_live_test.go — 對**真實實例**跑一次既有資料修復(`inkstone/Arcrun#167`)。
|
||||
//
|
||||
// 🔴 為什麼留在 repo 裡而不是用完就丟(同 probe_real_manual_test.go 的理由):
|
||||
// 這一票的驗收條件是「**MCP client 問『原文在哪』拿到的答案能不能走到那個檔**」——
|
||||
// 那不是 fixture 答得出來的。下一個人要重跑存量修復(換機器、換庫、雲端重裝過)
|
||||
// 就跑這一支,不必再從頭想一次怎麼推。
|
||||
//
|
||||
// 預設 skip,不進 CI。它會**真的寫進知識庫**,所以要明講打的是哪個實例:
|
||||
//
|
||||
// LIVE_REPAIR=1 \
|
||||
// LIVE_CYPHER=https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev \
|
||||
// LIVE_NS=<namespace> \
|
||||
// LIVE_ROOTS="/Users/x/Desktop/youlinhsieh-test1" \
|
||||
// # 加 LIVE_FORCE=1 =不看本機是不是已經新形,一律重推(雲端被清空、或上一輪中斷過時用)
|
||||
// go test -run TestLiveRepairSourceBlocks -v ./
|
||||
//
|
||||
// 🔴 紅線:LIVE_CYPHER 只准指測試實例。本測試不讀 config.json(那份含多個帳號,
|
||||
// 誤跑會打到不該打的實例)——實例位址一律由呼叫者當場指名。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveRepairSourceBlocks(t *testing.T) {
|
||||
if os.Getenv("LIVE_REPAIR") == "" {
|
||||
t.Skip("設 LIVE_REPAIR=1 才跑(會真的寫進知識庫)")
|
||||
}
|
||||
cypher := strings.TrimSpace(os.Getenv("LIVE_CYPHER"))
|
||||
ns := strings.TrimSpace(os.Getenv("LIVE_NS"))
|
||||
roots := strings.Split(os.Getenv("LIVE_ROOTS"), ",")
|
||||
if cypher == "" || ns == "" || len(roots) == 0 {
|
||||
t.Fatal("需要 LIVE_CYPHER / LIVE_NS / LIVE_ROOTS")
|
||||
}
|
||||
if strings.Contains(cypher, "leo21c") {
|
||||
t.Fatal("紅線:不准打 leo21c")
|
||||
}
|
||||
for _, root := range roots {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
cfg := &DirectConfig{
|
||||
CypherURL: strings.TrimSuffix(cypher, "/"), Namespace: ns, APIKey: ns,
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
MachineLabel: os.Getenv("LIVE_MACHINE_LABEL"),
|
||||
Manifest: os.Getenv("LIVE_MANIFEST"), // 空=機器身分不落檔,只在記憶體鑄
|
||||
}
|
||||
// 用一份**丟棄式**的 Manifest:修復的蓋章不寫進正在跑的 daemon 的帳本,
|
||||
// 免得跟它搶同一個檔(daemon 之後自己會再跑一次,重推是取代不是疊加)。
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
res := repairCardSourceBlocks(cfg, root, m, false, os.Getenv("LIVE_FORCE") != "", time.Now())
|
||||
if res == nil {
|
||||
t.Logf("%s:沒有需要修的卡", root)
|
||||
continue
|
||||
}
|
||||
t.Logf("%s:掃 %d 份、本機重畫 %d 份、重推 %d 份、err=%q",
|
||||
root, res.Scanned, res.Rewrote, res.Repushed, res.Err)
|
||||
if res.Err != "" {
|
||||
t.Errorf("%s 有失敗:%s", root, res.Err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const legacyCard = `---
|
||||
tags: [測試]
|
||||
gloss: 測試卡
|
||||
created: 2026-08-01
|
||||
updated: 2026-08-01
|
||||
---
|
||||
# 小果被AFTEE詐貸
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
一段摘要。
|
||||
|
||||
## 重點
|
||||
- 一條重點
|
||||
|
||||
## 實體
|
||||
- **小果**(人物)— 當事人
|
||||
|
||||
## 關聯
|
||||
### 內文知識關係
|
||||
- 小果 >` + `> 被詐貸 >` + `> AFTEE
|
||||
### 卡片關係
|
||||
- [[小果被AFTEE詐貸]] >` + `> 整理出 >` + `> [[貸款爭議]]
|
||||
### 出處
|
||||
- ` + "`../小果被AFTEE詐貸.pdf`" + ` >` + `> 提及 >` + `> 小果被AFTEE詐貸
|
||||
`
|
||||
|
||||
// TestRepairCardSourceBlocks 驗既有資料的處置:舊形出處就地改寫+原樣重推。
|
||||
// 這是票 `inkstone/Arcrun#167` 驗收條件「不能只修新的」的機械閘。
|
||||
func TestRepairCardSourceBlocks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cardRel := "子資料夾/.wiki/小果被AFTEE詐貸.md"
|
||||
if err := os.MkdirAll(filepath.Join(root, "子資料夾", ".wiki"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(cardRel)), []byte(legacyCard), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wm := &wikiManifest{Version: 1, Docs: []wikiDoc{{
|
||||
Node: "子資料夾", Path: "小果被AFTEE詐貸.pdf", Status: "extracted",
|
||||
Card: "小果被AFTEE詐貸", Cards: []string{cardRel},
|
||||
}}}
|
||||
if err := saveWikiManifest(root, wm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &got)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &DirectConfig{
|
||||
CypherURL: srv.URL, APIKey: "k", Namespace: "ns",
|
||||
CardIngestWF: "rag_ingest_card",
|
||||
MachineLabel: "教育部 Leo 的 Mac",
|
||||
}
|
||||
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
|
||||
|
||||
res := repairCardSourceBlocks(cfg, root, m, false, false, time.Now())
|
||||
if res == nil || res.Repushed != 1 {
|
||||
t.Fatalf("沒有重推:%+v", res)
|
||||
}
|
||||
|
||||
// ① 本機卡不再有內部相對路徑,且定位得到子資料夾。
|
||||
fixed, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(cardRel)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := string(fixed)
|
||||
if strings.Contains(card, "../") {
|
||||
t.Fatalf("本機卡仍帶 `../`:\n%s", card)
|
||||
}
|
||||
if !strings.Contains(card, "子資料夾/小果被AFTEE詐貸.pdf") {
|
||||
t.Fatalf("本機卡的出處定位不到子資料夾:\n%s", card)
|
||||
}
|
||||
if !strings.Contains(card, "教育部 Leo 的 Mac") {
|
||||
t.Fatalf("本機卡的出處沒有機器標記:\n%s", card)
|
||||
}
|
||||
// ② 出處以外一字不動(重萃會讓內容變樣,就地修不會)。
|
||||
if !strings.Contains(card, "## 摘要\n一段摘要。") || !strings.Contains(card, "- **小果**(人物)— 當事人") {
|
||||
t.Fatalf("出處以外的內容被動到了:\n%s", card)
|
||||
}
|
||||
|
||||
// ③ 送上雲的 payload 與 direct.go 送新卡時同一組欄位,且路徑是**庫內完整路徑**。
|
||||
if got["path"] != "子資料夾/小果被AFTEE詐貸.pdf" {
|
||||
t.Fatalf("重推的 path 不對:%v", got["path"])
|
||||
}
|
||||
if got["page_name"] != "小果被AFTEE詐貸" {
|
||||
t.Fatalf("重推的 page_name 不對:%v", got["page_name"])
|
||||
}
|
||||
if s, _ := got["card_content"].(string); strings.Contains(s, "../") {
|
||||
t.Fatalf("送上雲的卡片內容仍帶 `../`:\n%s", s)
|
||||
}
|
||||
|
||||
// ④ 修完蓋章 ⇒ 不會每輪重掃、更不會每輪重推。
|
||||
if m.SourceOriginRepairedAt == 0 {
|
||||
t.Fatal("修完沒蓋章,會變成每輪都跑的背景工作")
|
||||
}
|
||||
got = nil
|
||||
if again := repairCardSourceBlocks(cfg, root, m, false, false, time.Now()); again != nil {
|
||||
t.Fatalf("蓋章後又跑了一次:%+v", again)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatal("蓋章後又重推了一次")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteSourceBlockLeavesNewShapeAlone:已經是新形的卡不該被再動一次
|
||||
//(避免「每輪都改一點」讓使用者的檔案 mtime 無故跳動)。
|
||||
func TestRewriteSourceBlockLeavesNewShapeAlone(t *testing.T) {
|
||||
o := SourceOrigin{MachineLabel: "m", Library: "kb", LibraryPath: "a/b.md"}
|
||||
var b strings.Builder
|
||||
renderSourceLine(&b, o, "卡")
|
||||
card := "## 關聯\n### 卡片關係\n" + b.String()
|
||||
if hasLegacySourceLine(card) {
|
||||
t.Fatalf("新形被誤判成舊形:\n%s", card)
|
||||
}
|
||||
if rewriteSourceBlock(card, o, "卡") != card {
|
||||
t.Fatal("新形卡被重寫了")
|
||||
}
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
// stallguard.go — 一發等不到回覆的請求,不可以讓整台機器的同步停下來
|
||||
// (`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 一輪同步是**一條線**走完的:帳號 → 看守資料夾 → 一件件事。整條線上每一發
|
||||
// HTTP 都是同步的,所以只要其中一發停在那裡不回來,它後面的所有帳號、所有
|
||||
// 資料夾就一起停擺——而且是**安靜地**停擺:日誌只有開工那一行,畫面上的
|
||||
// 資料夾結構永遠停在上一版,沒有任何錯誤訊息。
|
||||
//
|
||||
// 為什麼不是「把 300 秒調小」:那只改變**卡多久**,沒改變「一發卡住就全停」。
|
||||
// 一個階段可以連續打二十發(見 sourcerepair.go 的 sourceRepairBatch),
|
||||
// 每發都等到超時的話,60 秒的上限一樣會把一輪拖成二十分鐘。
|
||||
//
|
||||
// 這支檔把「等待」變成三件有邊界、會說話的事:
|
||||
//
|
||||
// ① **每一發自己的上限**——按「這一發實際上在做什麼」給(會同步跑 AI 萃取的
|
||||
// 那一發本來就慢,機械收口的那些不該也享有五分鐘),而且用 context 帶進
|
||||
// 請求裡,不是只靠 client 那一把套用全部的總閘。
|
||||
// ② **同一個帳號連續等不到回覆 ⇒ 這一輪不再打它**(斷路器)。一發卡住的代價
|
||||
// 從「整輪停擺」降成「這個帳號這一輪跳過」,其他帳號、其他資料夾照常跑完。
|
||||
// ③ **等待要有話說**——超過 stallNoticeEvery 就往 stdout 播一句
|
||||
// 「哪個帳號、哪件事、等了多久」,並在收工時寫進 status.json。
|
||||
// 靜默的等待跟當掉對使用者是同一件事。
|
||||
//
|
||||
// 🔴 播報用 `phase:"waiting"`,**不能沿用既有的兩種**:supervisor 把任何
|
||||
// 非 `start` 的 JSON 值都當成「一輪跑完了」(supervisor.go 的 decode 迴圈),
|
||||
// 沿用會讓托盤在同步途中跳回「看守中」——正是 t191 修掉的那個病。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// callStep 描述「這一發網路呼叫在做什麼」。
|
||||
//
|
||||
// Name 會出現在日誌與畫面上,所以它是**產品文案**:講使用者認得的那件事,
|
||||
// 不出現 workflow 名、狀態碼、模型名。
|
||||
type callStep struct {
|
||||
Name string
|
||||
Budget time.Duration
|
||||
}
|
||||
|
||||
// 🔴 **每一個數字都是量出來的,不是推出來的**(2026-08-28 第三輪的教訓)。
|
||||
//
|
||||
// 第一輪我用「零 LLM 的機械收口應該很快」這個**推論**給了 60 秒,結果實測分佈是:
|
||||
//
|
||||
// 送出一份筆記 33.2 39.9 42.4 43.2 43.3 43.5 43.6 44.2 44.6 44.6
|
||||
// 45.5 53.1 53.3 53.6 54.5 54.8 57.3 ← 真實尾巴到 57 秒
|
||||
// 60.0 60.0 60.0 ← **這三個是我自己的上限把它剪斷**,不是雲端的耗時
|
||||
//
|
||||
// 後果不是「多等一下」:那 2 個被剪斷的檔判成 failed,斷路器跟著跳,
|
||||
// **後面 9 個檔全部被跳過**,而畫面對使用者說的是「知識庫現在沒有回應」
|
||||
// ——它一路都在回應,只是慢。**我的推論把一台健康的機器講成壞掉的。**
|
||||
//
|
||||
// ⇒ 判準改成:**上限只用來擋「真的不回來」,不用來表達「我認為它該多快」。**
|
||||
// 每一格都給實測最大值的數倍餘裕;要調它,先去量(`ARCRUN_TRACE=1`)。
|
||||
const (
|
||||
// writeBudget=任何會把知識寫進雲端的呼叫(收卡/直送萃取/萃取/修出處/下架/收回)。
|
||||
// 實測最大 57.3 秒(送出一份筆記)⇒ 300 秒 ≈ 5 倍餘裕。
|
||||
// 這也正是改版前 directHTTP 用的數字——事實證明它本來就沒選錯。
|
||||
writeBudget = 300 * time.Second
|
||||
// registerBudget=只寫登記簿、不產生知識的呼叫(資料夾總覽/目錄索引/資料夾結構)。
|
||||
// 實測 1.8〜26.3 秒 ⇒ 180 秒 ≈ 7 倍餘裕。
|
||||
registerBudget = 180 * time.Second
|
||||
// probeBudget=「這台還活著嗎」的唯讀探問。實測 0.0〜1 秒。
|
||||
// 它短是因為**它問的就是活著沒**,不是因為我覺得它該多快。
|
||||
probeBudget = 20 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
stepIngestDoc = callStep{"整理一份文件", writeBudget}
|
||||
stepIngestCard = callStep{"送出一份筆記", writeBudget}
|
||||
stepExtractDoc = callStep{"請雲端讀一份文件", writeBudget}
|
||||
stepRepairOrigin = callStep{"更新舊筆記的原文位置", writeBudget}
|
||||
stepTakedown = callStep{"把刪掉的檔案從雲端下架", writeBudget}
|
||||
stepRetire = callStep{"收回這個資料夾在雲端的資料", writeBudget}
|
||||
stepFolderTree = callStep{"回報資料夾結構", registerBudget}
|
||||
stepInventory = callStep{"送出資料夾總覽", registerBudget}
|
||||
stepFolderCard = callStep{"送出目錄索引", registerBudget}
|
||||
// stepProbeAI=每輪每個帳號的第一發(探測雲端 AI 通了沒)。它同時是最早
|
||||
// 能認出「這個帳號今天不回應」的位置——認出來,這個帳號其餘的工作就都省了。
|
||||
stepProbeAI = callStep{"確認雲端 AI 可不可以用", probeBudget}
|
||||
// stepCloudAudit=跟雲端核對「先前送過的檔案還在不在」。唯讀查詢,
|
||||
// 一輪可能連打 cloudAuditBatch 發——正是「一發卡住的代價會被乘上批次大小」的例子。
|
||||
stepCloudAudit = callStep{"跟雲端核對哪些檔案還在", probeBudget}
|
||||
)
|
||||
|
||||
// stallNoticeEvery=等多久開口說一次「還在等」。變數而非常數:測試要把它調快。
|
||||
var stallNoticeEvery = 30 * time.Second
|
||||
|
||||
// stallStrikesBeforeSkip=同一個帳號連續幾發等到超時,就這一輪不再打它。
|
||||
//
|
||||
// 為什麼是 2 而不是 1:一次偶發的逾時不該讓整個帳號這一輪停手。
|
||||
// 為什麼不是更多:每多一次就是多等一個 Budget,而這正是本票要砍掉的成本。
|
||||
var stallStrikesBeforeSkip = 2
|
||||
|
||||
// StalledCall=這一輪「等太久」的一件事,寫進 status.json 讓畫面講得出
|
||||
// 「哪個帳號、哪件事、等了多久」。
|
||||
type StalledCall struct {
|
||||
Account string `json:"account"` // 知識庫網址的主機名(使用者在畫面上看得到的那個)
|
||||
Step string `json:"step"` // 白話的「哪件事」
|
||||
WaitedSec int `json:"waited_sec"` // 等了幾秒
|
||||
Skipped bool `json:"skipped"` // 這一輪之後不再打這個帳號
|
||||
Note string `json:"note,omitempty"` // 給使用者看的一句話
|
||||
}
|
||||
|
||||
// accountStall=某個帳號在這一輪的「等待帳」。
|
||||
type accountStall struct {
|
||||
strikes int
|
||||
answered bool // 這一輪它**成功回應過** ⇒ 不准說它「沒有回應」
|
||||
skip string // 非空=這一輪不再打它,內容是給使用者看的理由
|
||||
}
|
||||
|
||||
// roundGuard 一輪一份,掛在 DirectConfig 上。
|
||||
// makeAccountSubConfig 的 `sub := *c` 會把這個**指標**一起帶過去
|
||||
// ⇒ 同一輪所有帳號、所有資料夾寫進同一份紀錄,斷路器才跨得了資料夾。
|
||||
type roundGuard struct {
|
||||
mu sync.Mutex
|
||||
accounts map[string]*accountStall
|
||||
stalls []StalledCall
|
||||
// announce=把「還在等」播出去。測試會換掉它(預設寫 stdout)。
|
||||
announce func(StalledCall)
|
||||
}
|
||||
|
||||
func newRoundGuard() *roundGuard {
|
||||
return &roundGuard{accounts: map[string]*accountStall{}, announce: announceStall}
|
||||
}
|
||||
|
||||
// stdoutMu 保護 stdout 上的「一個 JSON 值」不被另一條 goroutine 插進去切成兩半。
|
||||
//
|
||||
// 🔴 為什麼非有不可:播報是在**另一條 goroutine** 上跑的,而每輪結束那筆
|
||||
// `phase:"done"` 可能有幾十 KB(results 逐筆展開)——超過管線的原子寫入大小之後,
|
||||
// 兩邊就會交錯。而 supervisor 的 json.Decoder 一旦讀到壞掉的值就
|
||||
// `io.Copy(io.Discard)` 跳出迴圈(supervisor.go)⇒ **那個行程接下來所有的
|
||||
// start/done 都不會再被看到**,托盤從此停在錯的狀態。
|
||||
// 機率很小,代價是整條狀態線靜默死掉——這種比例的東西要用鎖解決,不是賭。
|
||||
var stdoutMu sync.Mutex
|
||||
|
||||
// printJSONLine 把一個值印成 stdout 上完整的一行(與播報共用同一把鎖)。
|
||||
func printJSONLine(v any) {
|
||||
line, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdoutMu.Lock()
|
||||
defer stdoutMu.Unlock()
|
||||
_, _ = os.Stdout.Write(append(line, '\n'))
|
||||
}
|
||||
|
||||
// announceStall 把一句「還在等」印成 stdout 上的一個 JSON 值。
|
||||
// 一次寫完整一個值:supervisor 那邊是 json.Decoder 逐值解,寫一半會炸掉整條線。
|
||||
func announceStall(s StalledCall) {
|
||||
line, err := json.Marshal(struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"`
|
||||
StalledCall
|
||||
}{time.Now().Format(time.RFC3339), "waiting", s})
|
||||
if err != nil {
|
||||
return // 播不出去也不能擋住本體
|
||||
}
|
||||
stdoutMu.Lock()
|
||||
defer stdoutMu.Unlock()
|
||||
_, _ = os.Stdout.Write(append(line, '\n'))
|
||||
}
|
||||
|
||||
// skipReason 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
|
||||
func (g *roundGuard) skipReason(host string) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if a := g.accounts[host]; a != nil {
|
||||
return a.skip
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Stalls 回這一輪等太久的清單(順序=發生順序)。
|
||||
func (g *roundGuard) Stalls() []StalledCall {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return append([]StalledCall(nil), g.stalls...)
|
||||
}
|
||||
|
||||
// succeeded 記一發「成功回來了」。
|
||||
//
|
||||
// 🔴 沒有這個函式的話,`strikes` 只增不減 ⇒ **一輪裡任意兩發逾時就會跳閘**,
|
||||
// 而訊息上寫的是「**連續** 2 件事都等不到回覆」——那兩個字就是假的。
|
||||
// 2026-08-28 實撞:一輪成功送出 8 份筆記、中間兩發被我自己過短的上限剪斷,
|
||||
// 斷路器就跳了,後面 9 個檔全被跳過。
|
||||
func (g *roundGuard) succeeded(host string) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
a := g.accounts[host]
|
||||
if a == nil {
|
||||
a = &accountStall{}
|
||||
g.accounts[host] = a
|
||||
}
|
||||
a.answered = true
|
||||
if a.skip == "" {
|
||||
a.strikes = 0 // 已經跳閘就不再回頭;沒跳閘的話,成功一次就重新計數
|
||||
}
|
||||
}
|
||||
|
||||
// strike 記一發「等到超時」,必要時讓這個帳號這一輪停手。回傳給使用者看的那句話。
|
||||
func (g *roundGuard) strike(host string, step callStep, waited time.Duration) string {
|
||||
g.mu.Lock()
|
||||
a := g.accounts[host]
|
||||
if a == nil {
|
||||
a = &accountStall{}
|
||||
g.accounts[host] = a
|
||||
}
|
||||
a.strikes++
|
||||
// 🔴 **它這一輪回應過的話,就不准說它「沒有回應」。**
|
||||
// 2026-08-28 實撞:youlin 一路都在回應(成功送出 8 份筆記),只是慢,
|
||||
// 而畫面對使用者說「知識庫現在沒有回應」——那是把一台健康的機器講成壞掉的。
|
||||
// 使用者會照著這句話去查網路、去重裝,全都白費。
|
||||
how := "沒有回應"
|
||||
if a.answered {
|
||||
how = "回得太慢"
|
||||
}
|
||||
tripped := a.strikes >= stallStrikesBeforeSkip && a.skip == ""
|
||||
if tripped {
|
||||
a.skip = fmt.Sprintf(
|
||||
"知識庫「%s」現在%s(連續 %d 件事都等不到回覆),這一輪先跳過它;"+
|
||||
"其他資料夾照常同步,稍後會自動恢復。",
|
||||
host, how, a.strikes)
|
||||
}
|
||||
// 🔴 結尾那句「稍後會自動恢復」不是修辭,它是 explainsWhySkipped 認得的識別字
|
||||
// (sync_status.go)。少了它,這些被跳過的檔會**連一句原因都沒有地從畫面消失**。
|
||||
// 有測試守著(TestSkipMessagesAlwaysExplainThemselves),改措辭前先看那條。
|
||||
note := fmt.Sprintf("「%s」等了 %d 秒,知識庫「%s」%s;這一輪先跳過,"+
|
||||
"其他資料夾照常同步,稍後會自動恢復。",
|
||||
step.Name, int(waited.Seconds()), host, how)
|
||||
g.stalls = append(g.stalls, StalledCall{
|
||||
Account: host, Step: step.Name, WaitedSec: int(waited.Seconds()),
|
||||
Skipped: tripped, Note: note,
|
||||
})
|
||||
announce, skip := g.announce, a.skip
|
||||
g.mu.Unlock()
|
||||
|
||||
if announce != nil {
|
||||
announce(StalledCall{Account: host, Step: step.Name,
|
||||
WaitedSec: int(waited.Seconds()), Skipped: tripped, Note: note})
|
||||
}
|
||||
// 🔴 把連線池裡那條可能已經死掉的連線丟掉。
|
||||
// 實撞的形狀(2026-08-28):行程活著、CPU 0%、`lsof` 一條 TCP 都沒有,
|
||||
// 而堆疊停在 HTTP/2 的 roundTrip——連線在作業系統那層已經沒了,
|
||||
// 連線池裡的殼卻還在,後面每一發都會被指派到同一個殼上、一發一發地等到超時。
|
||||
// 丟掉之後下一發會重新建立連線,而不是繼續排在一條死掉的連線後面。
|
||||
directHTTP.CloseIdleConnections()
|
||||
|
||||
if skip != "" {
|
||||
return skip
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// unreachableNote 回「這個帳號這一輪已經被判定沒有回應」的白話理由;空=照常打。
|
||||
//
|
||||
// 呼叫端用它在**打之前**就掉頭,而不是打下去等到超時再說——那正是「一發卡住
|
||||
// 就整輪停擺」的成本來源:不掉頭的話,一個沒有回應的帳號會讓這一輪的每一件事
|
||||
// 各自再等一個 Budget。
|
||||
func (c *DirectConfig) unreachableNote() string {
|
||||
return c.guard.skipReason(instanceHostOf(c.CypherURL))
|
||||
}
|
||||
|
||||
// callGate=一發網路呼叫的閘。用法(三行,缺一不可):
|
||||
//
|
||||
// gate := cfg.openGate(stepXxx)
|
||||
// defer gate.release() // 停掉播報、放掉 context
|
||||
// if note := gate.blocked(); note != "" { … 這一輪不再打這個帳號 … }
|
||||
// … 真的去打 …
|
||||
// if err != nil { return gate.record(err) } // 逾時才會被記帳,其餘原樣回
|
||||
type callGate struct {
|
||||
g *roundGuard
|
||||
host string
|
||||
step callStep
|
||||
started time.Time
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stop chan struct{}
|
||||
once sync.Once
|
||||
skip string
|
||||
// notice=多久播報一次。**在 openGate 就抄成自己的一份**,播報那條 goroutine
|
||||
// 不再去讀套件層的變數——那條 goroutine 的生命週期比呼叫端長一點點,
|
||||
// 讀共用變數就是一個真的資料競爭(-race 抓到的)。
|
||||
notice time.Duration
|
||||
}
|
||||
|
||||
// openGate 開一發呼叫的閘。guard 為 nil(測試直接呼叫低層函式)時仍回一個可用的
|
||||
// 閘:有 context deadline、不記帳、不播報——「沒裝 guard」不該變成「沒有上限」。
|
||||
func (c *DirectConfig) openGate(step callStep) *callGate {
|
||||
host := instanceHostOf(c.CypherURL)
|
||||
gate := &callGate{g: c.guard, host: host, step: step, started: time.Now(),
|
||||
stop: make(chan struct{}), notice: stallNoticeEvery}
|
||||
if c.guard != nil {
|
||||
gate.skip = c.guard.skipReason(host)
|
||||
}
|
||||
gate.ctx, gate.cancel = context.WithTimeout(context.Background(), step.Budget)
|
||||
if gate.skip != "" {
|
||||
return gate // 已經跳閘:不必播報,呼叫端會立刻回頭
|
||||
}
|
||||
go gate.keepTalking()
|
||||
return gate
|
||||
}
|
||||
|
||||
// keepTalking 在等待期間每隔 stallNoticeEvery 播一句「還在等」。
|
||||
// 這是本票驗收條件②:卡住的那一發要有話說,而不是靜默。
|
||||
func (gate *callGate) keepTalking() {
|
||||
if gate.g == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(gate.notice)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-gate.stop:
|
||||
return
|
||||
case now := <-t.C:
|
||||
waited := int(now.Sub(gate.started).Seconds())
|
||||
gate.g.mu.Lock()
|
||||
announce := gate.g.announce
|
||||
gate.g.mu.Unlock()
|
||||
if announce != nil {
|
||||
announce(StalledCall{
|
||||
Account: gate.host, Step: gate.step.Name, WaitedSec: waited,
|
||||
Note: fmt.Sprintf("還在等知識庫「%s」回覆「%s」,已經等了 %d 秒。",
|
||||
gate.host, gate.step.Name, waited),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blocked 回「這一輪已經不打這個帳號了」的理由;空=可以打。
|
||||
func (gate *callGate) blocked() string { return gate.skip }
|
||||
|
||||
// traceCalls=把每一發的「哪件事/打誰/花了多久」印到 stderr(`ARCRUN_TRACE=1` 開)。
|
||||
//
|
||||
// 🔴 為什麼要留這個:2026-08-28 第一次修完之後,端到端症狀還在,而現場能拿到的只有
|
||||
// 一張 SIGQUIT 堆疊——堆疊只說得出「此刻卡在哪一發」,**說不出「這一輪的時間花到哪去了」**。
|
||||
// 兩個人為此各自推測了一輪。有這一行就不必推測:跑一次,時間分佈直接列出來。
|
||||
var traceCalls = os.Getenv("ARCRUN_TRACE") != ""
|
||||
|
||||
func (gate *callGate) trace(outcome string) {
|
||||
if !traceCalls {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[trace] %7.1fs %-22s %-8s %s\n",
|
||||
time.Since(gate.started).Seconds(), gate.step.Name, outcome, gate.host)
|
||||
}
|
||||
|
||||
// release 停掉播報並放掉 context。**一定要 defer**:context 活到呼叫端讀完回應
|
||||
// 之後才釋放,所以不能在讀 body 之前呼叫。
|
||||
func (gate *callGate) release() {
|
||||
gate.once.Do(func() { close(gate.stop); gate.trace("done") })
|
||||
gate.cancel()
|
||||
}
|
||||
|
||||
// ok 記一發成功(見 roundGuard.succeeded)。與 record 成對,兩者呼叫端都要顧到。
|
||||
func (gate *callGate) ok() { gate.g.succeeded(gate.host) }
|
||||
|
||||
// record 記一發失敗。只有「等到超時」才進帳(連線被拒之類的錯是**很快**回來的,
|
||||
// 不是本票要修的病,記進去只會讓正常的斷網把帳號誤判成沒有回應)。
|
||||
// 回傳要交給呼叫端的錯誤:逾時換成白話,其餘原樣。
|
||||
//
|
||||
// 可以在 release 之後呼叫——它不碰 context。
|
||||
func (gate *callGate) record(err error) error {
|
||||
if err == nil || !isStallError(err) {
|
||||
return err
|
||||
}
|
||||
waited := time.Since(gate.started)
|
||||
if gate.g == nil {
|
||||
return fmt.Errorf("「%s」等了 %d 秒,知識庫「%s」沒有回應;稍後會自動恢復。",
|
||||
gate.step.Name, int(waited.Seconds()), gate.host)
|
||||
}
|
||||
return errors.New(gate.g.strike(gate.host, gate.step, waited))
|
||||
}
|
||||
|
||||
// isStallError 回答「這個錯誤是**等到超時**嗎」。
|
||||
//
|
||||
// 三種都要認得:我們自己的 context deadline、client.Timeout 包出來的
|
||||
// `*url.Error{Timeout:true}`、以及被 `%w` 包過好幾層之後的同一個東西
|
||||
//(萃取那條路會加上「連不上你的知識庫:」再包一層)。
|
||||
func isStallError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// stallguard_test.go — 「一發卡住就整輪停擺」這個形狀的網(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 🔴 這裡測的**不是**「逾時設多久」。把 300 秒調小照樣過不了這幾條,因為病不在
|
||||
// 那個數字上:一個階段可以連續打二十發,每發都等到超時的話,上限設多小都會把
|
||||
// 一輪拖垮。這幾條驗的是**執行模型**:
|
||||
//
|
||||
// ① 一個帳號的端點不回應 ⇒ 另一個帳號、另一個資料夾照樣跑完一輪(驗收條件①)
|
||||
// ② 卡住的那一發要有話說:哪個帳號、哪件事、等了多久(驗收條件②)
|
||||
// ③ 一輪跑完 folder-trees.json 真的被重寫(驗收條件③)——這是使用者眼中
|
||||
// 「畫面永遠停在上一版」的那一格
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stallTestTimings 把等待相關的旋鈕調成測試尺度,回傳復原函式。
|
||||
// 調的是「等多久算久」,不是「有沒有上限」——上限的存在本身才是被測的東西。
|
||||
func stallTestTimings(t *testing.T, budget, notice time.Duration) {
|
||||
t.Helper()
|
||||
oldPlain, oldLLM := stepIngestCard, stepIngestDoc
|
||||
oldRepair, oldTree, oldInv, oldCard := stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard
|
||||
oldExtract, oldTakedown, oldRetire := stepExtractDoc, stepTakedown, stepRetire
|
||||
oldProbe, oldAudit := stepProbeAI, stepCloudAudit
|
||||
oldNotice := stallNoticeEvery
|
||||
// 🔴 **每一個** step 都要調到——漏掉一個,那一個就是新的瓶頸。
|
||||
// 這不是測試的細節,是本票的形狀本身:2026-08-28 第一版就是這樣,斷路器
|
||||
// 明明跳了,一輪還是要 20 秒,因為漏掉了每輪第一發的探測。
|
||||
for _, s := range []*callStep{
|
||||
&stepIngestCard, &stepIngestDoc, &stepRepairOrigin, &stepFolderTree,
|
||||
&stepInventory, &stepFolderCard, &stepExtractDoc, &stepTakedown, &stepRetire,
|
||||
&stepProbeAI, &stepCloudAudit,
|
||||
} {
|
||||
s.Budget = budget
|
||||
}
|
||||
stallNoticeEvery = notice
|
||||
t.Cleanup(func() {
|
||||
stepIngestCard, stepIngestDoc = oldPlain, oldLLM
|
||||
stepRepairOrigin, stepFolderTree, stepInventory, stepFolderCard = oldRepair, oldTree, oldInv, oldCard
|
||||
stepExtractDoc, stepTakedown, stepRetire = oldExtract, oldTakedown, oldRetire
|
||||
stepProbeAI, stepCloudAudit = oldProbe, oldAudit
|
||||
stallNoticeEvery = oldNotice
|
||||
})
|
||||
}
|
||||
|
||||
// 一個永遠不回應的知識庫(把請求放進黑洞,直到測試結束)。
|
||||
func blackHoleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-done:
|
||||
case <-r.Context().Done(): // 呼叫端自己放棄了=正是我們要的行為
|
||||
}
|
||||
}))
|
||||
t.Cleanup(func() { close(done); srv.Close() })
|
||||
return srv
|
||||
}
|
||||
|
||||
// 一個正常回應的知識庫,順便數它被打過幾次。
|
||||
func countingServer(t *testing.T, hits *int) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ① 一個帳號的端點不回應,另一個帳號照樣跑完一輪;而且整輪的時間有上限。
|
||||
//
|
||||
// 沒有這道閘之前的行為:第一個帳號的第一發 POST 停在那裡不回來,
|
||||
// 第二個帳號一次都不會被碰到,folder-trees.json 也永遠不會被重寫。
|
||||
func TestOneDeadAccountDoesNotStopTheRound(t *testing.T) {
|
||||
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
liveHits := 0
|
||||
live := countingServer(t, &liveHits)
|
||||
|
||||
deadRoot, liveRoot := t.TempDir(), t.TempDir()
|
||||
for _, r := range []string{deadRoot, liveRoot} {
|
||||
if err := os.WriteFile(filepath.Join(r, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{
|
||||
{CypherURL: dead.URL, Namespace: "dead", APIKey: "k", WatchFolders: []string{deadRoot}},
|
||||
{CypherURL: live.URL, Namespace: "live", APIKey: "k", WatchFolders: []string{liveRoot}},
|
||||
},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
// 萃取那條路不是這條測試的題目:這裡驗的是「不回應的端點會不會把整輪鎖住」。
|
||||
// 空 Extractor=走原文直送(rag_ingest_direct),一樣是打同一批端點。
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
results, _, _ := RunDirectOnce(cfg, false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 🔴 核心:一輪要跑得完,而且時間跟「壞掉的那個帳號有幾件事要做」無關。
|
||||
// 上限抓得寬(真實情境下每發 300ms × 斷路器 2 次 + 活著那邊的工作),
|
||||
// 但遠小於「每一發都各等一次」的量級——沒有斷路器時這裡會是好幾秒起跳。
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("🔴 一輪花了 %v——一發等不到回覆就把整輪拖住了,正是本票要修的形狀", elapsed)
|
||||
}
|
||||
|
||||
// 活著的那個帳號真的被服務到了(不是「兩個都被跳過所以很快」)。
|
||||
if liveHits == 0 {
|
||||
t.Fatalf("🔴 活著的帳號一次都沒被打到——壞掉的那個把整輪吃光了\nresults=%+v", results)
|
||||
}
|
||||
|
||||
// 斷路器要真的跳過:同一個帳號不該被逐檔、逐階段各等一次。
|
||||
stalls := cfg.guard.Stalls()
|
||||
if len(stalls) == 0 {
|
||||
t.Fatalf("🔴 等了那麼久卻一句話都沒說——使用者會看到「開著、沒錯誤、不動」")
|
||||
}
|
||||
tripped := false
|
||||
for _, s := range stalls {
|
||||
if s.Skipped {
|
||||
tripped = true
|
||||
}
|
||||
}
|
||||
if !tripped {
|
||||
t.Fatalf("🔴 沒有任何一筆標成「這一輪跳過這個帳號」,斷路器沒生效:%+v", stalls)
|
||||
}
|
||||
if len(stalls) > 4 {
|
||||
t.Fatalf("🔴 等了 %d 次才停手——斷路器該在 %d 次就跳,不然一輪還是會被拖垮:%+v",
|
||||
len(stalls), stallStrikesBeforeSkip, stalls)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 卡住的那一發要講得出「哪個帳號、哪件事、等了多久」。
|
||||
func TestStallSaysWhichAccountWhichStepHowLong(t *testing.T) {
|
||||
stallTestTimings(t, 300*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
// 🔴 播報會從**兩條** goroutine 進來(等待期間那條、以及記帳時呼叫端那條),
|
||||
// 所以收集它的地方自己要上鎖——這不是測試的潔癖,是 -race 抓出來的真實併發。
|
||||
var mu sync.Mutex
|
||||
var said []StalledCall
|
||||
g := newRoundGuard()
|
||||
g.announce = func(s StalledCall) { mu.Lock(); said = append(said, s); mu.Unlock() }
|
||||
|
||||
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k", guard: g}
|
||||
_, _, err := cfg.postJSON(stepRepairOrigin, dead.URL+"/x", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("端點不回應卻回了 nil error")
|
||||
}
|
||||
|
||||
// 播報:等待期間就要開口,不是等到最後才說。
|
||||
mu.Lock()
|
||||
saidCount := len(said)
|
||||
mu.Unlock()
|
||||
if saidCount == 0 {
|
||||
t.Fatal("🔴 等待期間一句話都沒播——靜默的等待跟當掉對使用者是同一件事")
|
||||
}
|
||||
host := instanceHostOf(dead.URL)
|
||||
mu.Lock()
|
||||
snapshot := append([]StalledCall(nil), said...)
|
||||
mu.Unlock()
|
||||
for _, s := range snapshot {
|
||||
if s.Account != host {
|
||||
t.Fatalf("沒講是哪個帳號:%+v(want %s)", s, host)
|
||||
}
|
||||
if s.Step != stepRepairOrigin.Name {
|
||||
t.Fatalf("沒講是哪件事:%+v(want %s)", s, stepRepairOrigin.Name)
|
||||
}
|
||||
}
|
||||
// 交給呼叫端的錯誤是產品文案:講哪件事、等多久、他會怎樣,不出現狀態碼/內部名詞。
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, stepRepairOrigin.Name) {
|
||||
t.Fatalf("錯誤訊息沒講是哪件事:%s", msg)
|
||||
}
|
||||
// 🔴 不再寫死「會自動恢復」這幾個字——改用產生端與消費端**共用**的那個判準。
|
||||
// 寫死字串正是第三輪的病:我改了措辭,兩邊就對不上了(見 explainsWhySkipped)。
|
||||
if !explainsWhySkipped(msg) {
|
||||
t.Fatalf("🔴 這句話不會被 status.json 收進失敗清單 ⇒ 畫面上會沒有原因:%s", msg)
|
||||
}
|
||||
for _, banned := range []string{"context deadline", "HTTP", "timeout", "Client.Timeout"} {
|
||||
if strings.Contains(msg, banned) {
|
||||
t.Fatalf("🔴 錯誤訊息漏出內部語彙「%s」:%s", banned, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ②之二:等太久的事要寫進 status.json,畫面才有東西可以講。
|
||||
func TestStallsLandInStatusJSON(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("# 一份筆記\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
st, err := LoadSyncStatus(StatusFilePath(manifestPath))
|
||||
if err != nil {
|
||||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||||
}
|
||||
if len(st.Stalls) == 0 {
|
||||
t.Fatal("🔴 status.json 沒有 stalls——畫面上就只剩「開著、沒錯誤、什麼都不動」")
|
||||
}
|
||||
s := st.Stalls[0]
|
||||
if s.Account == "" || s.Step == "" || s.Note == "" {
|
||||
t.Fatalf("三件事要講齊(哪個帳號/哪件事/人話):%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 一輪跑完,folder-trees.json 真的被重寫——即使雲端那邊完全不回應。
|
||||
//
|
||||
// 這一格就是使用者實際撞到的症狀:畫面上的資料夾結構永遠停在上一版。
|
||||
// 樹是**本機算出來的**,不該因為送不上雲端就連本機那份都不寫。
|
||||
func TestFolderTreeStoreRewrittenEvenWhenCloudNeverAnswers(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"a", "b", "c"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, d, "n.md"), []byte("# n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: dead.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
RunDirectOnce(cfg, false)
|
||||
|
||||
treePath := FolderTreeStorePath(manifestPath)
|
||||
store, err := LoadFolderTreeStore(treePath)
|
||||
if err != nil {
|
||||
t.Fatalf("🔴 一輪跑完卻沒有 folder-trees.json:%v", err)
|
||||
}
|
||||
tree, ok := store.Trees[root]
|
||||
if !ok {
|
||||
t.Fatalf("快照裡沒有這個根:%+v", store.Trees)
|
||||
}
|
||||
if len(tree.Nodes) < 4 { // 根 + a/b/c
|
||||
t.Fatalf("🔴 樹只有 %d 個節點(該有根+三個子資料夾):%+v", len(tree.Nodes), tree.Nodes)
|
||||
}
|
||||
|
||||
// 再跑一輪,mtime 要往前走——「畫面永遠停在上一版」的反面就是這一格。
|
||||
before, err := os.Stat(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
RunDirectOnce(cfg, false)
|
||||
after, err := os.Stat(treePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !after.ModTime().After(before.ModTime()) {
|
||||
t.Fatalf("🔴 第二輪沒有重寫 folder-trees.json(%v → %v)", before.ModTime(), after.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
// 連線被拒(很快就回來的錯)不該被當成「等太久」——
|
||||
// 把它記進斷路器的話,正常的斷網會讓帳號被誤判成沒有回應。
|
||||
func TestFastFailureIsNotCountedAsStall(t *testing.T) {
|
||||
stallTestTimings(t, 2*time.Second, 50*time.Millisecond)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close() // 關掉 ⇒ 連線立刻被拒
|
||||
|
||||
g := newRoundGuard()
|
||||
g.announce = func(StalledCall) {}
|
||||
cfg := &DirectConfig{CypherURL: url, Namespace: "n", APIKey: "k", guard: g}
|
||||
if _, _, err := cfg.postJSON(stepIngestCard, url+"/x", map[string]any{}); err == nil {
|
||||
t.Fatal("連不上卻回 nil error")
|
||||
}
|
||||
if len(g.Stalls()) != 0 {
|
||||
t.Fatalf("🔴 立刻失敗被記成「等太久」:%+v", g.Stalls())
|
||||
}
|
||||
if g.skipReason(instanceHostOf(url)) != "" {
|
||||
t.Fatal("🔴 一次連線被拒就把整個帳號停掉了——那是把斷網懲罰成停機")
|
||||
}
|
||||
}
|
||||
|
||||
// 沒裝 guard(低層函式被單獨呼叫)時仍然要有上限——
|
||||
// 「沒裝閘」不可以等於「沒有上限」,那是這條線最初出事的樣子。
|
||||
func TestGateAlwaysHasADeadlineEvenWithoutGuard(t *testing.T) {
|
||||
stallTestTimings(t, 200*time.Millisecond, 50*time.Millisecond)
|
||||
|
||||
dead := blackHoleServer(t)
|
||||
cfg := &DirectConfig{CypherURL: dead.URL, Namespace: "n", APIKey: "k"} // guard == nil
|
||||
start := time.Now()
|
||||
_, _, err := cfg.postJSON(stepIngestCard, dead.URL+"/x", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("端點不回應卻回了 nil error")
|
||||
}
|
||||
if el := time.Since(start); el > 2*time.Second {
|
||||
t.Fatalf("🔴 沒有 guard 就沒有上限了(等了 %v)", el)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 慢但**成功**的雲端,不准被講成「沒有回應」,也不准害後面的檔被跳過。
|
||||
//
|
||||
// 這一條是 2026-08-28 第三輪的實撞:`送出一份筆記` 實測 33〜57 秒,而我第一輪
|
||||
// 憑「零 LLM 應該很快」的**推論**給了 60 秒上限 ⇒ 尾巴被剪斷 2 發 ⇒ 斷路器跳 ⇒
|
||||
// **後面 9 個檔全被跳過**,畫面還對使用者說「知識庫現在沒有回應」。
|
||||
// 那台知識庫一路都在回應(同一輪成功送出 8 份筆記),只是慢。
|
||||
func TestSlowButSucceedingCloudIsNotCalledUnresponsive(t *testing.T) {
|
||||
// 上限給 2 秒,伺服器每發 300ms——**遠低於上限**,就是「慢但會成功」。
|
||||
stallTestTimings(t, 2*time.Second, 50*time.Millisecond)
|
||||
|
||||
origFetch := fetchCloudVersion
|
||||
fetchCloudVersion = func(string) (string, bool) { return "", false }
|
||||
defer func() { fetchCloudVersion = origFetch }()
|
||||
|
||||
var hits int64
|
||||
slow := slowButHealthyServer(t, 300*time.Millisecond, &hits)
|
||||
|
||||
root := t.TempDir()
|
||||
for i := range [8]struct{}{} {
|
||||
name := filepath.Join(root, "note"+string(rune('a'+i))+".md")
|
||||
if err := os.WriteFile(name, []byte("# 筆記\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
Manifest: manifestPath,
|
||||
Accounts: []AccountConfig{{CypherURL: slow.URL, Namespace: "n", APIKey: "k",
|
||||
WatchFolders: []string{root}}},
|
||||
Library: "kb", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
CardIngestWF: "rag_ingest_card", IngestWF: "rag_ingest_direct", RemovedWF: "rag_takedown_direct",
|
||||
ForceSync: true,
|
||||
}
|
||||
results, _, _ := RunDirectOnce(cfg, false)
|
||||
|
||||
// ① 斷路器不准跳:它一路都在回應。
|
||||
if note := cfg.unreachableNote(); note != "" {
|
||||
t.Fatalf("🔴 慢但成功的雲端被判成不能打了:%s", note)
|
||||
}
|
||||
for _, s := range cfg.guard.Stalls() {
|
||||
if s.Skipped {
|
||||
t.Fatalf("🔴 有一筆標成「這一輪跳過該帳號」:%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 一個檔都不准被「帳號沒回應」這個理由跳過。
|
||||
skipped := 0
|
||||
for _, r := range results {
|
||||
if r.Status == "skipped" && (strings.Contains(r.Error, "沒有回應") ||
|
||||
strings.Contains(r.Error, "回得太慢")) {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
if skipped > 0 {
|
||||
t.Fatalf("🔴 有 %d 個檔因為「帳號沒回應」被跳過,但雲端每一發都成功了", skipped)
|
||||
}
|
||||
|
||||
// ③ 就算真的有一發逾時,只要它這一輪回應過,措辭就不准是「沒有回應」。
|
||||
g := newRoundGuard()
|
||||
g.announce = func(StalledCall) {}
|
||||
g.succeeded("h")
|
||||
msg := g.strike("h", stepIngestCard, 3*time.Second)
|
||||
if strings.Contains(msg, "沒有回應") {
|
||||
t.Fatalf("🔴 它回應過,卻還是說「沒有回應」:%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "回得太慢") {
|
||||
t.Fatalf("措辭應該是「回得太慢」:%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 「連續」兩個字必須是真的:成功一次就把計數歸零。
|
||||
func TestStrikesAreActuallyConsecutive(t *testing.T) {
|
||||
g := newRoundGuard()
|
||||
g.announce = func(StalledCall) {}
|
||||
g.strike("h", stepIngestCard, time.Second) // 第 1 次逾時
|
||||
g.succeeded("h") // 中間成功了一次
|
||||
g.strike("h", stepIngestCard, time.Second) // 又逾時一次 ⇒ 這是「第 1 次連續」
|
||||
if r := g.skipReason("h"); r != "" {
|
||||
t.Fatalf("🔴 中間成功過,卻仍然跳閘了——訊息裡的「連續」是假的:%s", r)
|
||||
}
|
||||
g.strike("h", stepIngestCard, time.Second) // 真的連續第 2 次 ⇒ 才該跳
|
||||
if g.skipReason("h") == "" {
|
||||
t.Fatal("真的連續兩次逾時了,該跳閘卻沒跳")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -46,12 +47,23 @@ type Status struct {
|
||||
Rounds int // 累計掃描輪數
|
||||
Restarts int // 累計重起次數
|
||||
LastError string // 最近一次錯誤(stderr 末行 / 退出原因)
|
||||
// Waiting=正在等某一發雲端回覆的白話說明(`inkstone/arcrun-rag#153`)。
|
||||
// 空=沒有人在等。它不是錯誤——所以不寫進 LastError(那一格會被診斷檔
|
||||
// 當成「出事了」帶出去,見 diagnostics_export.go 的 engineLastErrorFor)。
|
||||
Waiting string
|
||||
}
|
||||
|
||||
// round 對應 collector direct 每輪印到 stdout 的 JSON(見 direct.go runOne)。
|
||||
type round struct {
|
||||
At string `json:"at"`
|
||||
Phase string `json:"phase"` // t191:"start"(開工)/"done"(跑完);舊版沒有此欄=空
|
||||
// `inkstone/arcrun-rag#153`:"waiting"=這一發等太久了,還沒跑完。
|
||||
// 🔴 它**不是**一輪的結束:下面的 decode 迴圈把任何非 start 的值都當成
|
||||
// 「跑完了」,不特別認得它的話,托盤會在同步途中跳回「看守中」——
|
||||
// 正是 t191 修掉的那個病(「看起來好像就做完了」)。
|
||||
Account string `json:"account,omitempty"` // 哪個知識庫帳號
|
||||
Step string `json:"step,omitempty"` // 哪件事(白話)
|
||||
WaitedSec int `json:"waited_sec,omitempty"` // 等了幾秒
|
||||
|
||||
Folder string `json:"folder"`
|
||||
Results []json.RawMessage `json:"results"`
|
||||
@@ -327,11 +339,21 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
at := parseAt(r.At)
|
||||
if r.Phase == "start" {
|
||||
// t191:開工 → 顯示「同步中…」。不累加 Rounds(那是「完成幾輪」)。
|
||||
s.setState(func(st *Status) { st.State = StateSyncing })
|
||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = "" })
|
||||
continue
|
||||
}
|
||||
if r.Phase == "waiting" {
|
||||
// #153:還在等某一發回覆 ⇒ 仍在同步中,**不算跑完一輪**。
|
||||
// 把「哪個帳號、哪件事、等了多久」留在狀態上,托盤才講得出來
|
||||
// ——不然使用者看到的就是「開著、沒有錯誤、什麼都不動」。
|
||||
note := fmt.Sprintf("正在等「%s」回覆「%s」,已經等了 %d 秒",
|
||||
r.Account, r.Step, r.WaitedSec)
|
||||
s.setState(func(st *Status) { st.State = StateSyncing; st.Waiting = note })
|
||||
continue
|
||||
}
|
||||
s.setState(func(st *Status) {
|
||||
st.State = StateWatching
|
||||
st.Waiting = "" // 跑完了=沒有人在等,別讓上一輪的等待訊息留在畫面上
|
||||
st.Rounds++
|
||||
if !at.IsZero() {
|
||||
st.LastRoundAt = at
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AccountSyncStatus 彙總單一帳號的每輪同步結果(t104 多帳號看守)。
|
||||
@@ -52,6 +53,23 @@ type RetiringStatus struct {
|
||||
LastError string `json:"last_error,omitempty"` // 最後一次失敗的真因(原文,不改寫)
|
||||
}
|
||||
|
||||
// ResyncStatus=某個監看資料夾的「雲端補送」現況(`inkstone/arcrun-rag#140`)。
|
||||
//
|
||||
// 🔴 為什麼一定要有畫面:這張票的病不只是「該送的沒送」,還有
|
||||
// 「**沒有任何地方會說話**」——使用者看到檔案在資料夾裡、AI 卻查不到,
|
||||
// 而且查不出為什麼。所以修法不能靜悄悄地重送:他要看得見
|
||||
// 「有 N 份在補送、不是你弄壞的、你不必做任何事」。
|
||||
//
|
||||
// 與 Retiring/FolderPlans 同族:**每輪照 manifest 現況重算的快照**,
|
||||
// 不進 CarryForwardActivity——補完就自然歸零,不必有人去清它。
|
||||
type ResyncStatus struct {
|
||||
Pending int `json:"pending"` // 章已拔掉、還沒補送成功的份數
|
||||
Repaired int `json:"repaired"` // 最近一天內已補送成功的份數
|
||||
CheckedAt string `json:"checked_at,omitempty"` // 最近一次對帳時間(RFC3339)
|
||||
Note string `json:"note,omitempty"` // 給使用者看的一句人話
|
||||
LastError string `json:"last_error,omitempty"` // 對帳本身失敗的真因(原文,不改寫)
|
||||
}
|
||||
|
||||
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
|
||||
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
|
||||
type SyncStatus struct {
|
||||
@@ -83,6 +101,27 @@ type SyncStatus struct {
|
||||
// 與 SkippedDocs 同族——每輪照現況重算的快照,不進 CarryForwardActivity。
|
||||
Retiring map[string]RetiringStatus `json:"retiring,omitempty"`
|
||||
|
||||
// `inkstone/arcrun-rag#140`:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
|
||||
// 與 Retiring 同族的現況快照,見 ResyncStatus 註解。
|
||||
Resync map[string]ResyncStatus `json:"resync,omitempty"`
|
||||
|
||||
// FolderProgress=**逐個看守資料夾**的同步現況(key=資料夾路徑,`inkstone/arcrun-rag#159`)。
|
||||
//
|
||||
// 🔴 為什麼要有:小幫手畫面上每個資料夾都要能一眼看出「同步了沒」,而在這之前
|
||||
// 那一格唯一的資料來源是 #140 的補送說明——**它只在有補送的時候才有值**,
|
||||
// 於是畫面只好拿「有沒有那句話」當狀態。2026-08-28 leo 看到七列有六列寫「補送中」,
|
||||
// 連 pending 已經是 0 的也照樣寫(實測 youlinhsieh-test1:pending=0/repaired=2,
|
||||
// 畫面仍標補送中)——那個標籤量的根本不是同步狀態。
|
||||
//
|
||||
// 🔴 **不另算一套**:值就是 (*Manifest).Progress() 的原件,與首頁那行大數字、
|
||||
// 診斷檔用同一個函式。逐資料夾加起來會等於首頁總量,因為它們本來就是同一組
|
||||
// 數字被切開,不是兩份實作。
|
||||
//
|
||||
// 與 FolderPlans 同族的現況快照;某一輪那個根沒掃成(payload 為 nil)時**沿用
|
||||
// 上一輪**,不要讓使用者看到昨天還好好的資料夾突然沒有狀態
|
||||
// (同 MergeFolderTreeStore ② 的理由)。
|
||||
FolderProgress map[string]SyncProgress `json:"folder_progress,omitempty"`
|
||||
|
||||
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
|
||||
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
|
||||
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。
|
||||
@@ -119,6 +158,17 @@ type SyncStatus struct {
|
||||
// 不會被清成 0(現況快照,不是本輪計數)。
|
||||
Progress SyncProgress `json:"progress"`
|
||||
FailureBreakdown FailureBreakdown `json:"failure_breakdown"`
|
||||
|
||||
// Stalls=這一輪「等太久」的事(`inkstone/arcrun-rag#153`)。
|
||||
//
|
||||
// 為什麼要有這一格:2026-08-28 實撞的畫面是**小幫手開著、沒有錯誤訊息、
|
||||
// 什麼都不動**——同步停在一發等不到回覆的請求上,而使用者看得到的每一個
|
||||
// 數字都還是上一輪的。**靜默的等待跟當掉對使用者是同一件事**,
|
||||
// 所以「哪個帳號、哪件事、等了多久」要有地方講。
|
||||
//
|
||||
// 與 SkippedDocs 同族:每輪重算的現況快照,不進 CarryForwardActivity
|
||||
//(上一輪等太久不代表這一輪也在等,帶下來就會變成一個永遠擦不掉的警告)。
|
||||
Stalls []StalledCall `json:"stalls,omitempty"`
|
||||
}
|
||||
|
||||
// FolderPlanStatus=某個看守資料夾這一輪用了什麼收檔策略、據此少收了什麼
|
||||
@@ -206,3 +256,28 @@ func LoadSyncStatus(path string) (SyncStatus, error) {
|
||||
func SyncNowSignalPath(manifestPath string) string {
|
||||
return filepath.Join(filepath.Dir(manifestPath), "sync-now")
|
||||
}
|
||||
|
||||
// explainsWhySkipped 回答:「這一則 `skipped` 的訊息,講得出**為什麼**嗎?」
|
||||
// 講得出 ⇒ 收進 status.json 的失敗清單,畫面才有原因可講;講不出 ⇒ 不佔畫面。
|
||||
//
|
||||
// 🔴 為什麼要抽成具名函式(`inkstone/arcrun-rag#153` 第三輪,2026-08-28 差點實撞):
|
||||
// 這個判準本來是**寫死在 direct.go 裡的三個字串比對**,而產生那些訊息的地方在別的檔。
|
||||
// 我改了斷路器的措辭(「會自動恢復」→「稍後會自動再試」),兩邊當場對不上——
|
||||
// 後果不是報錯,是**那 9 個被跳過的檔會連一句原因都沒有地從畫面上消失**,
|
||||
// 正是這個 repo 一再修的「安靜地略過」。
|
||||
//
|
||||
// 抽成一個函式解不掉「字串比對很脆」這件事,但它解掉了**兩邊會各自漂走**:
|
||||
// 現在只有一個地方定義「講得出原因」,而且有測試守著(sync_status_test.go)。
|
||||
// 新增訊息時,讓它通過這個函式,或把新的識別字加在這裡——不要在別處另開一張表。
|
||||
func explainsWhySkipped(msg string) bool {
|
||||
for _, mark := range []string{
|
||||
"後重試", // 退避中:「上次失敗(第 N 次),X 後重試」
|
||||
"已暫停自動重試", // 連續失敗到上限
|
||||
"會自動恢復", // 額度冷卻/帳號暫時打不通,之後自己會好
|
||||
} {
|
||||
if strings.Contains(msg, mark) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── FindClaudeBin fallback(t92)──────────────────────────────────────────────
|
||||
@@ -268,3 +270,46 @@ func TestCarryForwardActivity(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 🔴 產生訊息的那一端,和決定「這則訊息要不要上畫面」的那一端,必須永遠對得上。
|
||||
//
|
||||
// 2026-08-28 第三輪差點實撞:我改了斷路器的措辭,判準沒跟著改 ⇒ 那 9 個被跳過的檔
|
||||
// 會**連一句原因都沒有地從畫面消失**。不會報錯、不會有紅字,就是安靜地不見。
|
||||
// 這條網把兩端綁在一起:斷路器講的每一種話,都要能被 explainsWhySkipped 認得。
|
||||
func TestSkipMessagesAlwaysExplainThemselves(t *testing.T) {
|
||||
g := newRoundGuard()
|
||||
g.announce = func(StalledCall) {}
|
||||
|
||||
// ① 從沒回應過的帳號(措辭:沒有回應)
|
||||
first := g.strike("dead.example", stepIngestCard, 60*time.Second)
|
||||
// ② 連續第二次 ⇒ 跳閘,這句會被貼在每一個被跳過的檔上
|
||||
tripped := g.strike("dead.example", stepIngestCard, 60*time.Second)
|
||||
// ③ 回應過、只是慢的帳號(措辭:回得太慢)
|
||||
g.succeeded("slow.example")
|
||||
slow := g.strike("slow.example", stepIngestCard, 60*time.Second)
|
||||
|
||||
for name, msg := range map[string]string{
|
||||
"第一次逾時": first,
|
||||
"跳閘(貼在被跳過的檔上)": tripped,
|
||||
"回得太慢": slow,
|
||||
} {
|
||||
if msg == "" {
|
||||
t.Fatalf("%s:訊息是空的", name)
|
||||
}
|
||||
if !explainsWhySkipped(msg) {
|
||||
t.Fatalf("🔴 %s 的訊息不會被 status.json 收進失敗清單 ⇒ "+
|
||||
"畫面上那些檔會沒有任何原因地消失。\n訊息:%s", name, msg)
|
||||
}
|
||||
// 產品文案:不准漏出內部語彙
|
||||
for _, banned := range []string{"HTTP", "context", "timeout", "goroutine", "token"} {
|
||||
if strings.Contains(msg, banned) {
|
||||
t.Fatalf("🔴 %s 漏出內部語彙「%s」:%s", name, banned, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反面:一句不講原因的話,不該被收進去(不然畫面會被噪音塞滿)
|
||||
if explainsWhySkipped("跳過") {
|
||||
t.Fatal("不講原因的訊息不該被當成「講得出原因」")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,21 @@ func TestTemplateFilesAreNotKnowledge(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// template 的東西(**沒有隱藏**)
|
||||
write("CLAUDE.md", "開發用設定")
|
||||
write("scripts/sdd-active-check.sh", "#!/bin/sh")
|
||||
write("system-dev/wiki/status.md", "開發用 wiki")
|
||||
// template 的東西(**沒有隱藏**)。
|
||||
// 🔴 2026-08-28(inkstone/Arcrun#180):內容改成從內嵌快照原樣鋪出來,
|
||||
// 不再自己捏一段假字串——判準已經從「路徑前綴」換成「內容與原版逐字相同」
|
||||
// (templateuntouched.go),假內容就不是範本空殼,那是使用者寫的東西。
|
||||
// 這也讓這支測試更貼近它要守的事故:**daemon 自己鋪下去的那份**不該被自己吃掉。
|
||||
fromSnapshot := func(rel string) string {
|
||||
data, err := templateFS.ReadFile(templateFSRoot + "/" + rel)
|
||||
if err != nil {
|
||||
t.Fatalf("內嵌範本快照裡沒有 %s:%v", rel, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
write("CLAUDE.md", fromSnapshot("CLAUDE.md"))
|
||||
write("scripts/sdd-active-check.sh", fromSnapshot("scripts/sdd-active-check.sh"))
|
||||
write("system-dev/wiki/status.md", fromSnapshot("system-dev/wiki/status.md"))
|
||||
// 使用者真正的內容
|
||||
write("我的筆記.md", "這是我要搜尋的東西")
|
||||
|
||||
@@ -58,3 +69,85 @@ func TestTemplateFilesAreNotKnowledge(t *testing.T) {
|
||||
res.SkippedOther, res.SkippedOtherNames)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateShellVsRealContent 釘住 inkstone/Arcrun#180(leo 2026-08-28)新增的那半題:
|
||||
//
|
||||
// 「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**」
|
||||
//
|
||||
// 同一棵 `system-dev/` 底下並存兩種東西(`youlinhsieh-test1` 實況):
|
||||
//
|
||||
// system-dev/wiki/status.md ← 安裝器鋪的空殼,逐字等於快照 ⇒ 不是知識
|
||||
// system-dev/wiki/cards/*.md ← 使用者資料夾裡真的卡 ⇒ 是知識
|
||||
//
|
||||
// 舊的路徑前綴判準把兩者一起丟掉。這支釘的就是「分辨得出來」,
|
||||
// 而且釘的是**兩個方向**——放行與誤殺都會紅。
|
||||
func TestTemplateShellVsRealContent(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)
|
||||
}
|
||||
}
|
||||
shell, err := templateFS.ReadFile(templateFSRoot + "/system-dev/wiki/status.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ① 原封不動的空殼
|
||||
write("system-dev/wiki/status.md", string(shell))
|
||||
// ② 同一個路徑的另一個檔,但使用者寫過(只差一行)
|
||||
write("system-dev/wiki/mistakes.md", string(shell)+"\n2026-08-28:我自己寫的一條。\n")
|
||||
// ③ 快照裡根本沒有的真內容——那 9 張卡就是這一種
|
||||
write("system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md", "# 火星座標\n這是一張真的卡。\n")
|
||||
|
||||
m := &Manifest{Entries: map[string]*ManifestEntry{}}
|
||||
res, err := Scan(root, m, ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, e := range res.Events {
|
||||
got[e.Path] = true
|
||||
}
|
||||
if got["system-dev/wiki/status.md"] {
|
||||
t.Error("原封不動的範本空殼被當成知識收了——紅線①「不准變成放行」")
|
||||
}
|
||||
if !got["system-dev/wiki/mistakes.md"] {
|
||||
t.Error("使用者只要寫過一個字就是他的知識,卻被當成範本丟掉")
|
||||
}
|
||||
if !got["system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md"] {
|
||||
t.Error("system-dev 底下真的卡沒被收——leo:「cards 在 system-dev 下…所以不允許跳過它」")
|
||||
}
|
||||
}
|
||||
|
||||
// 判準不看名字:同一份範本鋪在叫別的名字的資料夾裡,一樣認得出來;
|
||||
// 而一個剛好也叫 system-dev、裡面是使用者東西的資料夾,不該再被誤殺。
|
||||
func TestTemplateShell_判準不看資料夾名字(t *testing.T) {
|
||||
shell, err := templateFS.ReadFile(templateFSRoot + "/system-dev/wiki/status.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if TemplateUntouched("我的專案筆記/wiki/status.md", "/nonexistent") {
|
||||
t.Error("快照裡沒有這個相對路徑,不該回 true")
|
||||
}
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "system-dev", "wiki", "status.md")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("這個資料夾剛好也叫 system-dev,但裡面是我的東西"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if TemplateUntouched("system-dev/wiki/status.md", p) {
|
||||
t.Error("名字一樣但內容不一樣,判準不該只看名字")
|
||||
}
|
||||
if err := os.WriteFile(p, shell, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !TemplateUntouched("system-dev/wiki/status.md", p) {
|
||||
t.Error("內容逐字等於快照,應該認得出是空殼")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// templateuntouched.go — 「範本空殼」與「真內容」怎麼分辨
|
||||
// (inkstone/Arcrun#180 驗收條件 2 與 3,leo 2026-08-28)。
|
||||
//
|
||||
// ── 題目 ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// leo 原話:「**cards 在 system-dev 下,wiki 就在裡面,所以不允許跳過它**」
|
||||
//
|
||||
// 實據(`~/Desktop/youlinhsieh-test1`,同一棵 `system-dev/` 底下兩種東西並存):
|
||||
//
|
||||
// system-dev/wiki/status.md ← 安裝器鋪的空殼,內容是「(初始化後填入)」
|
||||
// system-dev/wiki/cards/*.md ← 9 張真的卡(火星座標/Leo-Hsieh-填答/小果被AFTEE詐貸…)
|
||||
//
|
||||
// 舊判準是**路徑前綴**(`TemplateOwns`:`system-dev/` 與 `scripts/` 整棵不是知識),
|
||||
// 它分不出這兩層——把空殼和真內容一起丟掉。
|
||||
//
|
||||
// 🔴 而修法不准是「把 system-dev 放行」(票上紅線①):那會讓所有裝過 template 的 repo
|
||||
// 開始收一堆一模一樣的空殼,變成另一個方向的錯。**要的是分辨。**
|
||||
//
|
||||
// ── 判準 ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// daemon **自己就是那個安裝器**(template_install.go 的 `//go:embed all:templatefs`
|
||||
// 把整份範本快照打進二進位)⇒ 原版就在手上,可以逐字比對,不必猜:
|
||||
//
|
||||
// 快照裡有同名檔,**且 sha256 逐字相同** ⇒ 空殼:安裝器鋪的,他一個字都沒動 ⇒ 不收
|
||||
// 快照裡沒有,或內容不一樣 ⇒ 他寫的/他放的 ⇒ 照常判斷
|
||||
//
|
||||
// **為什麼它不會再誤殺一份現成的 wiki**:範本的 `status.md` 寫的是「(初始化後填入)」,
|
||||
// **使用者只要寫過一個字,雜湊就對不上,判斷立刻翻面**。
|
||||
// 判準不看名字、不看深度、不看模式 ⇒ 換個資料夾名、換個安裝位置,答案都一樣
|
||||
// (這也是紅線:不准用白名單,「下一個叫別的名字的資料夾照樣中」)。
|
||||
//
|
||||
// ⚠️ 這支只回答「是不是原封不動的範本」。**其餘的收檔判斷一條都沒有動**
|
||||
// (副檔名白名單、`.gitignore`、模式範圍、隱藏檔)——它只取代 `TemplateOwns`
|
||||
// 在掃描時的那一格。tidy.go 仍然用 `TemplateOwns`:它問的是另一題
|
||||
// (「哪些是範本殘留、該搬進收容處」),不是「這是不是知識」。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// templateSnapshotHashes:內嵌範本快照裡每個檔的 sha256(key=斜線相對路徑)。
|
||||
// 只算一次——快照是編譯期就固定的東西,執行期不會變。
|
||||
var templateSnapshotHashes = sync.OnceValue(func() map[string]string {
|
||||
out := map[string]string{}
|
||||
_ = fs.WalkDir(templateFS, templateFSRoot, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(templateFSRoot, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
data, rerr := templateFS.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
out[filepath.ToSlash(rel)] = hex.EncodeToString(sum[:])
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
// TemplateUntouched 回答「這個檔是不是安裝器鋪下去、而且使用者一個字都沒動過的空殼」。
|
||||
//
|
||||
// relSlash=相對監看根的路徑(斜線分隔);absPath=那個檔在磁碟上的位置。
|
||||
//
|
||||
// 🔴 讀不到檔一律回 false——**認不出來就不要排除**。誤留一份空殼只是雜訊,
|
||||
// 誤丟一份使用者的知識是資料遺失,兩邊的代價不對等(同 cleanup.go 的規矩②)。
|
||||
func TemplateUntouched(relSlash, absPath string) bool {
|
||||
want, ok := templateSnapshotHashes()[filepath.ToSlash(relSlash)]
|
||||
if !ok {
|
||||
return false // 快照裡沒有這個路徑 ⇒ 不是安裝器鋪的
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:]) == want
|
||||
}
|
||||
@@ -119,7 +119,37 @@ func collectCardItems(absRoot string) []TidyItem {
|
||||
for _, relDir := range cardDirsToScan() {
|
||||
base := filepath.Join(absRoot, filepath.FromSlash(relDir))
|
||||
_ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// 🔴 inkstone/Arcrun#134(2026-08-28 實測):**隱藏目錄整棵跳過。**
|
||||
//
|
||||
// `<node>/.wiki/` 是規範定的卡片正式落點(machinemark.go 例外 ③),
|
||||
// 而卡片產物區 `system-dev/wiki/cards/` 底下也會長出一個。少了這一行,
|
||||
// 這支就把 `cards/.wiki/換柱.md`「歸位」成 `cards/arcrun-換柱.md`
|
||||
// ——把**產物搬回 Scan 看得見的地方**,於是下一輪它被當成新原稿再萃一次,
|
||||
// 產出「卡片的卡片」,再被搬出來⋯⋯迴圈不會停。
|
||||
// 實測 `youlinhsieh-test1`:`cards/` 20 分鐘內從 9 個檔長到 55 個,
|
||||
// 出現 `arcrun-換柱(arcrun-換柱).md` 這種第三代檔名。
|
||||
//
|
||||
// `collectTemplateItems` 同一輪(f71e5a2)就寫了同一行同一個理由
|
||||
// (「收容處自己就在 `.arcrun-rag/` 底下,不跳過的話第二次跑會把
|
||||
// 搬過去的東西再搬一次」)——這支漏掉了。當時 `.wiki/` 還不存在
|
||||
// (2026-08-15 才隨 ef5e6c5 進來),所以不是被否決,是沒人回來補。
|
||||
//
|
||||
// 📌 `inkstone/Arcrun#180` 從**另一面**撞到同一個洞,兩件事一起記著:
|
||||
// · #134 看到的是「產物被搬出去 ⇒ 下一輪再萃 ⇒ 卡萃卡」
|
||||
// · #180 看到的是「萃取端**讀不到自己一秒前寫的卡**」——
|
||||
// 8 份文件全部 `讀卡片失敗:open …/.wiki/結晶管線.md: no such file`,
|
||||
// 於是 `system-dev/wiki/cards/` 底下那 9 張現成的卡一張都上不了雲端。
|
||||
// ⇒ 同一行修好兩個症狀。
|
||||
// ⚠️ 只跳**子**目錄(`p != base`):`.arcrun-rag/wiki/cards` 是 base 自己,
|
||||
// #60 第三輪正是把卡搬進那裡——連 base 一起跳掉等於把上一輪的修復關掉。
|
||||
// `TestMigrateCardNames_收容處自己仍然掃得到` 守這一格。
|
||||
if d.IsDir() {
|
||||
if p != base && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := d.Name()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// tidy_wiki_untouched_test.go — 舊制的「卡片歸位」不准動新制 `.wiki/` 裡的卡
|
||||
// (inkstone/Arcrun#180,2026-08-28 實撞)。
|
||||
//
|
||||
// 為什麼需要這一張網:`MigrateCardNames` 是 **daemon 每輪自動跑**的(direct.go
|
||||
// runDirectOnceRoot 開頭),而它掃的目錄與新制卡的家在某些資料夾上**完全重疊**——
|
||||
// 使用者把整理好的卡放在 `system-dev/wiki/cards/`,那些卡自己就是原稿,
|
||||
// 萃出來的新卡於是落在 `system-dev/wiki/cards/.wiki/`。
|
||||
//
|
||||
// 兩條規則撞上的當下沒有人會看到錯誤訊息:舊制默默把卡搬走並改名,
|
||||
// 萃取端下一步才報「讀卡片失敗」,而那句話指向的是**它自己一秒前寫出來的檔**。
|
||||
// ⇒ 這種 bug 只有機械網抓得到,靠讀程式碼讀不出來(本票就是這樣被繞了三輪)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustWriteFile(t *testing.T, root, rel, body string) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCardNames_不准把新制wiki裡的卡拖出來(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// leo 的 youlinhsieh-test1 的形狀:整理好的卡住在 system-dev/wiki/cards/,
|
||||
// 它們自己被當原稿萃過一輪,新卡落在同一層的 .wiki/。
|
||||
mustWriteFile(t, root, "system-dev/wiki/cards/arcrun-火星座標_短片劇本_v1.md", "# 火星座標\n")
|
||||
mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/火星座標_短片劇本_v1.md", "# 火星座標\n")
|
||||
mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/官架子.md", "# 官架子\n")
|
||||
mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/00-INDEX.md", "# 00-INDEX\n")
|
||||
mustWriteFile(t, root, "system-dev/wiki/cards/.wiki/.gitignore", "*\n")
|
||||
|
||||
MigrateCardNames(root)
|
||||
|
||||
for _, rel := range []string{
|
||||
"system-dev/wiki/cards/.wiki/火星座標_短片劇本_v1.md",
|
||||
"system-dev/wiki/cards/.wiki/官架子.md",
|
||||
"system-dev/wiki/cards/.wiki/00-INDEX.md",
|
||||
"system-dev/wiki/cards/.wiki/.gitignore",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil {
|
||||
t.Errorf("%s 不見了——舊制的歸位又走進 .wiki/ 了:%v", rel, err)
|
||||
}
|
||||
}
|
||||
// 反面:不准在上一層生出帶前綴的複本(那正是「卡萃卡」增生的起點)。
|
||||
for _, rel := range []string{
|
||||
"system-dev/wiki/cards/arcrun-官架子.md",
|
||||
"system-dev/wiki/cards/arcrun-00-INDEX.md",
|
||||
"system-dev/wiki/cards/arcrun-.gitignore",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil {
|
||||
t.Errorf("多出了 %s——`.wiki/` 裡的東西被拖出來改名了", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上一輪(#60 第三輪)的修復不准被這次的「跳過點開頭目錄」關掉:
|
||||
// `.arcrun-rag/wiki/cards/` 這個**收容處自己**是隱藏路徑,它仍然要被掃到。
|
||||
func TestMigrateCardNames_收容處自己仍然掃得到(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWriteFile(t, root, ".arcrun-rag/wiki/cards/沒帶標記的舊卡.md", "# 舊卡\n")
|
||||
|
||||
mig := MigrateCardNames(root)
|
||||
|
||||
if mig.Moved != 1 {
|
||||
t.Fatalf("收容處裡沒帶標記的舊卡應該仍然被認出來(#60 第三輪的行為),實際 moved=%d", mig.Moved)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".arcrun-rag/wiki/cards/沒帶標記的舊卡.md")); err == nil {
|
||||
t.Fatal("它應該已經歸位了,原位置不該還在")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// triggeroutcome.go — 「雲端收下了」不等於「知識庫寫進去了」(`inkstone/arcrun-rag#104`)。
|
||||
//
|
||||
// 🔴 為什麼有這支檔(2026-08-26 實測,非推測):
|
||||
// 把 leo 真實的 `InkStoneCo` 接上 youlin 跑一輪,daemon 對 26 份檔案蓋了「已送達」章,
|
||||
// **雲端實際只有 4 份**。使用者畫面上是綠的,AR-Mira 一句都查不到。
|
||||
//
|
||||
// 真兇不是萃取、不是排除規則,是這一行:`postJSON` 只看 HTTP 狀態碼。
|
||||
// named-webhook 觸發成功一律回 **200**,而工作流內部有沒有把東西寫進 KBDB
|
||||
// 藏在 body 裡。當天的實際回應(原文照抄):
|
||||
//
|
||||
// HTTP 200
|
||||
// {"success":true,"data":{"success":false,"status":500,
|
||||
// "error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}
|
||||
//
|
||||
// ⇒ 外層說 success,內層說 500/unreachable。daemon 讀外層 ⇒ 蓋章 ⇒
|
||||
// content_hash 沒變就永遠不會重送 ⇒ **這份知識永久消失,而且沒有人會知道**。
|
||||
//
|
||||
// 這與 `Arcrun#135`(push_workflow 對跑不起來的定義回「部署成功!」)是同一個病:
|
||||
// **回報層與事實層分居兩處,而讀的人只讀得到回報層。**
|
||||
// 我們改不了別人回什麼,但可以改「我們信什麼」——2xx 只證明請求送達,
|
||||
// 要證明寫進去了,得看 body。
|
||||
//
|
||||
// 三條自我約束:
|
||||
// - **只在看得懂的時候才判失敗**:body 不是 JSON、或沒有任何 success 欄位
|
||||
// ⇒ 回「看不出來」=照舊當成功。寧可漏判,不可把一次格式變更變成全面停擺。
|
||||
// - **不猜語意**:只認 `success:false` 這個明確訊號,不去猜 data 裡別的欄位。
|
||||
// - **講人話**:訊息會出現在使用者畫面上,不裸露狀態碼與上游 JSON 原文
|
||||
// (同 direct_quota_test.go 那份禁字表)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// triggerEnvelope=named-webhook 觸發端點的回應外殼。
|
||||
// `Data` 用 json.RawMessage:它可能是物件、陣列、字串,甚至 null——
|
||||
// 硬綁成 map 會在形狀一變時整條路 panic 或誤判。
|
||||
type triggerEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// triggerInner=工作流最後一個節點的輸出裡,我們唯一認得的兩個欄位。
|
||||
type triggerInner struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// webhookFailure 檢查「HTTP 2xx 的觸發回應裡,工作流是不是其實失敗了」。
|
||||
//
|
||||
// 回空字串=沒看出失敗(真的成功,或這個回應我們看不懂——兩者都放行)。
|
||||
// 回非空=**確定失敗**,字串是給使用者看的那句話。
|
||||
func webhookFailure(body string) string {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) {
|
||||
return "" // 不是 JSON ⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
var env triggerEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return "" // 解析不了(含被截斷)⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
// ① 外層自己就說失敗
|
||||
if env.Success != nil && !*env.Success {
|
||||
return ingestFailureSentence(env.Error)
|
||||
}
|
||||
// ② 外層說成功,但工作流的輸出說失敗——本檔存在的理由就是這一格
|
||||
if len(env.Data) > 0 {
|
||||
var inner triggerInner
|
||||
if err := json.Unmarshal(env.Data, &inner); err == nil && inner.Success != nil && !*inner.Success {
|
||||
return ingestFailureSentence(inner.Error)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ingestFailureSentence 把上游那串技術文字換成一句使用者讀得懂的話。
|
||||
//
|
||||
// 🔴 不是 debug 訊息,是產品文案:使用者看到「已整理 26 份」卻查不到東西的當下,
|
||||
// 唯一能讓他知道發生什麼事的就是這句(#104 的紅線:不要讓他猜)。
|
||||
// 認不出來的原因不編故事,只誠實說「雲端沒有寫進去」。
|
||||
func ingestFailureSentence(raw string) string {
|
||||
const head = "雲端收下了,但你的知識庫沒有真的寫進去(這一份還查不到)"
|
||||
switch {
|
||||
case strings.Contains(raw, "unreachable"):
|
||||
return head + ":連不到知識庫的資料層。稍後會自動再試。"
|
||||
case strings.Contains(raw, "card_content 為空"):
|
||||
return head + ":這份檔萃出來是空的。"
|
||||
case strings.Contains(raw, "credential"):
|
||||
return head + ":知識庫的內部金鑰不對,要重裝一次雲端才會通。"
|
||||
default:
|
||||
return head + ",稍後會自動再試。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realFailingEnvelope 是 2026-08-26 從 youlin 實例抄回來的**原文**:
|
||||
// HTTP 200、外層 success=true,而工作流其實一個字都沒寫進 KBDB。
|
||||
// 這一份不是虛構的樣本,是這一票的物證——改動這裡等於改動證據。
|
||||
const realFailingEnvelope = `{"success":true,"data":{"success":false,"status":500,` +
|
||||
`"error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}`
|
||||
|
||||
func TestWebhookFailure_實測那份回應必須被判成失敗(t *testing.T) {
|
||||
msg := webhookFailure(realFailingEnvelope)
|
||||
if msg == "" {
|
||||
t.Fatal("外層 success=true、內層 success=false ⇒ 必須判失敗,否則就是 2026-08-26 那個假綠")
|
||||
}
|
||||
if !strings.Contains(msg, "沒有真的寫進去") {
|
||||
t.Errorf("訊息要講出「沒寫進去」這件事:%q", msg)
|
||||
}
|
||||
// 使用者看得到這句話 ⇒ 不准出現狀態碼與上游 JSON 原文(同 direct_quota_test.go 的禁字表)
|
||||
for _, bad := range []string{"500", "HTTP", "unreachable", "{"} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("訊息裸露了技術細節 %q:%q", bad, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookFailure_只在看得懂的時候才判失敗(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
fail bool
|
||||
}{
|
||||
{"一切正常", `{"success":true,"data":{"success":true,"ok":1},"duration_ms":9}`, false},
|
||||
{"外層就說失敗", `{"success":false,"error":"workflow not found"}`, true},
|
||||
{"內層說失敗", realFailingEnvelope, true},
|
||||
{"data 沒有 success 欄位=看不出來", `{"success":true,"data":{"written":3}}`, false},
|
||||
{"data 是陣列", `{"success":true,"data":[1,2,3]}`, false},
|
||||
{"data 是字串", `{"success":true,"data":"done"}`, false},
|
||||
{"data 是 null", `{"success":true,"data":null}`, false},
|
||||
{"根本不是 JSON", `OK`, false},
|
||||
{"空回應", ``, false},
|
||||
{"被截斷的 JSON", `{"success":true,"data":{"success":false,"err`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := webhookFailure(c.body) != ""
|
||||
if got != c.fail {
|
||||
t.Errorf("%s:判失敗=%v,預期 %v(body=%s)", c.name, got, c.fail, c.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 這支是本票的核心迴歸閘:**「已送達」這個章,只能在東西真的寫進去時才准蓋。**
|
||||
// 沒有它,同一個假綠會以任何一種新的包裝再回來一次
|
||||
//(2026-08-26 實錄:26 份蓋章、雲端 4 份,而畫面全綠)。
|
||||
func TestWiring_雲端說沒寫進去就不准蓋已送達的章(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var triggers int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
triggers++
|
||||
// 就是實例當天回的那一份:200 + 外層綠、內層紅
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, realFailingEnvelope)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if triggers == 0 {
|
||||
t.Fatal("測試沒打到觸發端點,這支測試等於沒驗到東西")
|
||||
}
|
||||
if exit == 0 {
|
||||
t.Errorf("一份都沒真的寫進去,exit 不該是 0:%+v", results)
|
||||
}
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("那份檔應該是 failed,不是「已送達」:%+v", fileResults)
|
||||
}
|
||||
if !strings.Contains(fileResults[0].Error, "沒有真的寫進去") {
|
||||
t.Errorf("失敗理由要講人話:%q", fileResults[0].Error)
|
||||
}
|
||||
// 最關鍵的一格:manifest 不准留下「已送達」的章——留了,content_hash 沒變就永遠不會重送。
|
||||
m, err := LoadManifest(manifestPath, root)
|
||||
if err != nil {
|
||||
t.Fatalf("讀 manifest:%v", err)
|
||||
}
|
||||
for rel, e := range m.Entries {
|
||||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||||
t.Errorf("%s 蓋了「已送達」章,但雲端根本沒收到 ⇒ 這份知識會永久消失", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 太大的檔:明知送出去一定失敗,就不要送——而且理由要是人話。
|
||||
func TestTooBigForWorkersAI_講人話且不送出去(t *testing.T) {
|
||||
if why := tooBigForWorkersAI(strings.Repeat("a", 1000), "小檔.md"); why != "" {
|
||||
t.Errorf("一般大小的檔不該被擋:%q", why)
|
||||
}
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes) // 中文一字 3 bytes ⇒ 一定超過
|
||||
why := tooBigForWorkersAI(big, "mistakes.md")
|
||||
if why == "" {
|
||||
t.Fatal("超過上限的檔要被擋下")
|
||||
}
|
||||
if !strings.Contains(why, "萬字") || !strings.Contains(why, "拆成") {
|
||||
t.Errorf("要講出多大、以及使用者能做什麼:%q", why)
|
||||
}
|
||||
for _, bad := range []string{"token", "131000", "HTTP", "8007", "llama"} {
|
||||
if strings.Contains(why, bad) {
|
||||
t.Errorf("訊息裸露技術細節 %q:%q", bad, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes)
|
||||
if err := os.WriteFile(filepath.Join(root, "巨檔.md"), []byte("# 巨檔\n\n"+big), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": "{}"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md", testOrigin())
|
||||
if err == nil {
|
||||
t.Fatal("太大的檔應該直接失敗")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("擋下的檔不該還打雲端一次(燒額度+佔佇列),實際打了 %d 次", hits)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "太大") {
|
||||
t.Errorf("理由要講人話:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func TestVaultFootprint_ForeignWikiFileNotClobbered(t *testing.T) {
|
||||
}
|
||||
|
||||
defer gemmaCardStub(t, cardFixture("會議記錄", "專案"))()
|
||||
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel); err == nil {
|
||||
if _, err := ExtractWithGemma("k-test", "gemma-test", root, srcRel, testOrigin()); err == nil {
|
||||
t.Fatal("目標被佔用時應報錯,不得無聲覆蓋")
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(cardDir, "會議記錄.md"))
|
||||
|
||||
+89
-10
@@ -386,13 +386,93 @@ func entityFallback(ents []WikiEntity, selfName, selfGloss string) []WikiEntity
|
||||
return []WikiEntity{{Name: selfName, Type: "概念", Desc: firstNonEmpty(selfGloss, "本卡描述的概念")}}
|
||||
}
|
||||
|
||||
func renderSourceLine(b *strings.Builder, srcRel, cardName string) {
|
||||
// SourceOrigin=「這份原稿實際在哪裡」的三件式:哪台機器 / 哪個庫 / 庫內什麼路徑。
|
||||
//
|
||||
// 🔴 為什麼要有這個型別(`inkstone/Arcrun#167`,leo 2026-08-27 用 n8n 實測抓到):
|
||||
// 從前「### 出處」寫的是 `../<檔名>`——那是**卡片檔相對於原檔**的路徑,
|
||||
// 也就是 daemon 自己的目錄結構(卡在 `<node>/.wiki/`,原檔在上一層)。
|
||||
// 它有兩個要命的地方:
|
||||
//
|
||||
// ① 它是**內部結構**,卻被寫進知識內容 ⇒ 任何讀到那塊的 AI 都照著答,
|
||||
// 而使用者拿著 `../小果被AFTEE詐貸.pdf` 走不到任何地方。
|
||||
// ② 原稿在子資料夾時,`../<檔名>` 把**資料夾整段弄丟了**
|
||||
// (實據:`system-dev/wiki/trees/2026-08-17-today.md` 的出處只剩
|
||||
// `../2026-08-17-today.md`)⇒ 連「同一個庫裡是哪一份」都定位不到。
|
||||
//
|
||||
// 三件式是**同一份 metadata 早就在送的那三格**(library/machine/source_path,
|
||||
// 見 direct.go 的 cardBody 與 rag_ingest_card 的 post_block)——
|
||||
// 這裡不是新增資料,是把已經有的東西寫成人看得懂的樣子。
|
||||
//
|
||||
// 🔴 為什麼**不寫絕對路徑**(票上要求說明判斷):卡會離開這台機器(上雲、被別台的
|
||||
// AI 讀到),而絕對路徑只在鑄它的那台成立。給了它,跨機器問「原文在哪」拿到的
|
||||
// 會是一個**看起來最精確、實際上打不開**的答案——那正是 `../` 這枚坑的同一個形狀。
|
||||
// 「機器 + 庫 + 庫內路徑」才是三台機器上都成立的座標:認出機器 → 打開那個庫的
|
||||
// 資料夾 → 走庫內路徑。絕對路徑該由**知道監看根在哪的那一端**(daemon/portal)
|
||||
// 現場組,不該固化進知識內容。
|
||||
type SourceOrigin struct {
|
||||
// MachineLabel=人看得懂的機器稱呼(daemon 的 machine.json / config 的 machine_label)。
|
||||
MachineLabel string
|
||||
// Library=這份原稿所屬的知識庫名(=監看根,daemon 的 libraryFor)。
|
||||
Library string
|
||||
// LibraryPath=**庫內**相對路徑,含子資料夾,一律 forward slash。
|
||||
// 與 metadata 的 source_path 同一個值 ⇒ 卡上寫的與雲端存的對得起來。
|
||||
LibraryPath string
|
||||
}
|
||||
|
||||
// unknownOriginMark=三件式缺格時的誠實標記。
|
||||
// 寧可讓使用者看到「(未知)」,也不要給一個看起來精確、實際上走不到的路徑
|
||||
// ——那正是 `../` 這枚坑的形狀。
|
||||
const unknownOriginMark = "(未知)"
|
||||
|
||||
// originSep=三件式之間的分隔符,與 portal 的來源顯示同一種
|
||||
//(`youlinhsieh@Leo-MBA › rt-lib › 檔名 第 4 段`)⇒ 使用者在兩個地方看到同一種形狀。
|
||||
const originSep = " › "
|
||||
|
||||
// Human 回一句「這份原文在哪」:`機器 › 知識庫 › 庫內路徑`。
|
||||
func (o SourceOrigin) Human() string {
|
||||
mach := strings.TrimSpace(o.MachineLabel)
|
||||
if mach == "" {
|
||||
mach = "機器" + unknownOriginMark
|
||||
}
|
||||
lib := strings.TrimSpace(o.Library)
|
||||
if lib == "" {
|
||||
lib = "知識庫" + unknownOriginMark
|
||||
}
|
||||
return mach + originSep + lib + originSep + o.pathOrUnknown()
|
||||
}
|
||||
|
||||
// Ref 回三元組主詞用的那個字串=`庫名/庫內路徑`。
|
||||
// 庫名未知時退回純庫內路徑——**任何情況下都不帶 `../`**。
|
||||
func (o SourceOrigin) Ref() string {
|
||||
lib := strings.TrimSpace(o.Library)
|
||||
if lib == "" {
|
||||
return o.pathOrUnknown()
|
||||
}
|
||||
return lib + "/" + o.pathOrUnknown()
|
||||
}
|
||||
|
||||
func (o SourceOrigin) pathOrUnknown() string {
|
||||
p := strings.TrimSpace(filepath.ToSlash(o.LibraryPath))
|
||||
p = strings.TrimPrefix(p, "./")
|
||||
if p == "" {
|
||||
return "庫內路徑" + unknownOriginMark
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// renderSourceLine 寫「### 出處」那一塊:**先一行人話位置,再一行三元組**。
|
||||
//
|
||||
// 人話那行刻意不含 `>>`:`## 關聯` 段的解析(`rag_ingest_card` 的 parse_card)
|
||||
// 只把含兩個 `>>` 的行收成三元組 ⇒ 它進得了知識內容、進不了圖,
|
||||
// 正好是我們要的(給人讀的句子不該變成一條邊)。
|
||||
func renderSourceLine(b *strings.Builder, o SourceOrigin, cardName string) {
|
||||
b.WriteString("### 出處\n")
|
||||
b.WriteString("- `" + srcRel + "`" + triSep + "提及" + triSep + cardName + "\n")
|
||||
b.WriteString("- 原文位置(機器" + originSep + "知識庫" + originSep + "庫內路徑):`" + o.Human() + "`\n")
|
||||
b.WriteString("- `" + o.Ref() + "`" + triSep + "提及" + triSep + cardName + "\n")
|
||||
}
|
||||
|
||||
// renderDocCard 文件卡(=這份文件的 hub):重點是判斷句、行內連到概念卡。
|
||||
func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel string) string {
|
||||
func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, origin SourceOrigin) string {
|
||||
allowed := map[string]bool{"00-INDEX": true, d.Card: true}
|
||||
for _, c := range conceptNames {
|
||||
allowed[c] = true
|
||||
@@ -442,12 +522,12 @@ func renderDocCard(d *wikiDoc, ex *DocExtract, conceptNames []string, srcRel str
|
||||
for _, c := range conceptNames {
|
||||
b.WriteString("- [[" + d.Card + "]]" + triSep + "整理出" + triSep + "[[" + c + "]]\n")
|
||||
}
|
||||
renderSourceLine(&b, srcRel, d.Card)
|
||||
renderSourceLine(&b, origin, d.Card)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderConceptCard 原子概念卡。extraRels=機械補上的反向邊(雙向連結保證)。
|
||||
func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, updated string,
|
||||
func renderConceptCard(c WikiConcept, cardName, docCard string, origin SourceOrigin, created, updated string,
|
||||
allowed map[string]bool, extraRels []string) string {
|
||||
var b strings.Builder
|
||||
renderFrontmatter(&b, cleanTags(c.Tags), firstNonEmpty(sanitizeProse(c.Gloss, map[string]bool{}), cardName), created, updated)
|
||||
@@ -497,7 +577,7 @@ func renderConceptCard(c WikiConcept, cardName, docCard, srcRel, created, update
|
||||
for _, line := range extraRels {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
renderSourceLine(&b, srcRel, cardName)
|
||||
renderSourceLine(&b, origin, cardName)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -525,7 +605,7 @@ func writeWikiFile(absRoot, dest string, content []byte) error {
|
||||
// BuildWikiDoc 把一份文件的萃取結果落成規範形的 `.wiki/` 產物。
|
||||
// 回傳本次產出的卡(相對監看根、文件卡在第一個)。
|
||||
// ex.NoConcept 為真(或概念數 0)時不產卡,改走「標空」路徑(回傳空清單)。
|
||||
func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Time) ([]string, error) {
|
||||
func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, origin SourceOrigin, now time.Time) ([]string, error) {
|
||||
if ex == nil {
|
||||
return nil, fmt.Errorf("BuildWikiDoc: 沒有萃取結果")
|
||||
}
|
||||
@@ -651,7 +731,6 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim
|
||||
}
|
||||
|
||||
// 渲染+寫檔(先清掉這份文件上一輪產的、這一輪不再存在的卡)。
|
||||
srcRel := "../" + base
|
||||
var newCards []string
|
||||
rel := func(cardName string) string {
|
||||
return filepath.ToSlash(filepath.Join(nodeFromKey(nodeKey), wikiRelDir, cardName+".md"))
|
||||
@@ -684,14 +763,14 @@ func BuildWikiDoc(absRoot, relPath, srcText string, ex *DocExtract, now time.Tim
|
||||
return r, writeWikiFile(absRoot, destAbs, []byte(content))
|
||||
}
|
||||
|
||||
docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, srcRel))
|
||||
docRel, err := writeCard(docCard, renderDocCard(&entry, ex, conceptNames, origin))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newCards = append(newCards, docRel)
|
||||
for i, c := range concepts {
|
||||
name := conceptNames[i]
|
||||
content := renderConceptCard(c, name, docCard, srcRel, created, todayOf(now), allowed, extraRels[name])
|
||||
content := renderConceptCard(c, name, docCard, origin, created, todayOf(now), allowed, extraRels[name])
|
||||
r, werr := writeCard(name, content)
|
||||
if werr != nil {
|
||||
return nil, werr
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testOrigin=測試用的三件式(機器/庫/庫內路徑)。
|
||||
// 內容不重要,重要的是**每個呼叫端都必須交出它**——`inkstone/Arcrun#167` 的教訓是
|
||||
// 「出處寫的是什麼」不能靠預設值,得由知道自己在哪一台、哪個庫的那一端說出來。
|
||||
func testOrigin() SourceOrigin {
|
||||
return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: "測試文件.md"}
|
||||
}
|
||||
|
||||
// wsOrigin=wikishape 測試用:庫名/機器固定,庫內路徑跟著受測的 relPath 走
|
||||
//(子資料夾的檔要驗「資料夾沒被弄丟」,所以這一格不能寫死)。
|
||||
func wsOrigin(relPath string) SourceOrigin {
|
||||
return SourceOrigin{MachineLabel: "test@Machine", Library: "test-lib", LibraryPath: relPath}
|
||||
}
|
||||
|
||||
// TestSourceOriginNeverEmitsInternalPath 是 `inkstone/Arcrun#167` 的迴歸閘。
|
||||
//
|
||||
// leo 2026-08-27 用 n8n 實測:問「原文 wiki 的位置在哪裏?」,AI 照著卡片內文答
|
||||
// `../小果被AFTEE詐貸.pdf`——**使用者拿著它走不到任何地方**。
|
||||
// 這裡把那個形狀鎖死:不管三件式缺哪一格,輸出都不准出現 `../`。
|
||||
func TestSourceOriginNeverEmitsInternalPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin SourceOrigin
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"三件齊全",
|
||||
SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "youlinhsieh-test1", LibraryPath: "小果被AFTEE詐貸.pdf"},
|
||||
[]string{"youlinhsieh@Leo-MBA › youlinhsieh-test1 › 小果被AFTEE詐貸.pdf", "`youlinhsieh-test1/小果被AFTEE詐貸.pdf`"},
|
||||
},
|
||||
{
|
||||
// 🔴 票上的驗收條件三:子資料夾裡的檔要定位得到。
|
||||
// 舊寫法 `../<檔名>` 會把整段資料夾弄丟(實據:inkstoneco 庫的
|
||||
// system-dev/wiki/trees/2026-08-17-today.md 出處只剩 `../2026-08-17-today.md`)。
|
||||
"子資料夾不准被弄丟",
|
||||
SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: "system-dev/wiki/trees/2026-08-17-today.md"},
|
||||
[]string{"inkstoneco › system-dev/wiki/trees/2026-08-17-today.md", "`inkstoneco/system-dev/wiki/trees/2026-08-17-today.md`"},
|
||||
},
|
||||
{
|
||||
"機器未知也要誠實,不得退回相對路徑",
|
||||
SourceOrigin{Library: "kb", LibraryPath: "a/b.md"},
|
||||
[]string{"機器(未知) › kb › a/b.md"},
|
||||
},
|
||||
{
|
||||
"庫未知",
|
||||
SourceOrigin{MachineLabel: "m", LibraryPath: "a/b.md"},
|
||||
[]string{"m › 知識庫(未知) › a/b.md", "`a/b.md`"},
|
||||
},
|
||||
{
|
||||
"路徑未知",
|
||||
SourceOrigin{MachineLabel: "m", Library: "kb"},
|
||||
[]string{"m › kb › 庫內路徑(未知)"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
renderSourceLine(&b, c.origin, "某卡")
|
||||
got := b.String()
|
||||
if strings.Contains(got, "../") {
|
||||
t.Fatalf("出處帶內部相對路徑:\n%s", got)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Fatalf("出處少了「%s」:\n%s", w, got)
|
||||
}
|
||||
}
|
||||
// 人話那行不准變成三元組(`## 關聯` 的解析只收含兩個分隔符的行)。
|
||||
human := strings.Split(strings.TrimSpace(got), "\n")[1]
|
||||
if strings.Contains(human, triSep) {
|
||||
t.Fatalf("人話位置那行含三元組分隔符,會被當成一條邊:%s", human)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWikiDocSourceLocatesSubfolderDoc 走完整落地路徑(不是只測 renderer):
|
||||
// 子資料夾裡的原稿,產出的文件卡與概念卡都要寫得出「庫內完整路徑」。
|
||||
func TestBuildWikiDocSourceLocatesSubfolderDoc(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rel := "專案/會議/週會.md"
|
||||
if err := os.MkdirAll(filepath.Join(root, "專案", "會議"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := "# 週會\n\n甲和乙討論了進度。\n"
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ex := &DocExtract{
|
||||
Gloss: "週會記錄", Summary: "甲乙討論進度", Points: []string{"討論了進度"},
|
||||
Concepts: []WikiConcept{{Name: "進度", Gloss: "專案進度", Summary: "專案進度的討論"}},
|
||||
}
|
||||
origin := SourceOrigin{MachineLabel: "youlinhsieh@Leo-MBA", Library: "inkstoneco", LibraryPath: rel}
|
||||
cards, err := BuildWikiDoc(root, rel, src, ex, origin, time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
t.Fatal("沒產出卡")
|
||||
}
|
||||
for _, c := range cards {
|
||||
body, rerr := os.ReadFile(filepath.Join(root, filepath.FromSlash(c)))
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
card := string(body)
|
||||
if strings.Contains(card, "../") {
|
||||
t.Fatalf("%s 仍帶 `../`:\n%s", c, card)
|
||||
}
|
||||
if !strings.Contains(card, "inkstoneco › 專案/會議/週會.md") {
|
||||
t.Fatalf("%s 的出處定位不到子資料夾:\n%s", c, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-14
@@ -40,7 +40,7 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) {
|
||||
src := "# 測試文件\n\n內文若干"
|
||||
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
|
||||
|
||||
cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow)
|
||||
cards, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -58,9 +58,16 @@ func TestBuildWikiDoc_CardShapeMatchesSpec(t *testing.T) {
|
||||
t.Fatalf("文件卡缺「%s」:\n%s", want, doc)
|
||||
}
|
||||
}
|
||||
// 出處路徑相對 .wiki,真的指得到原稿(lint「出處路徑指不到原稿」項)
|
||||
if !strings.Contains(doc, "`../測試文件.md`") {
|
||||
t.Fatalf("出處路徑不對:\n%s", doc)
|
||||
// 🔴 `inkstone/Arcrun#167`:出處寫的是「哪台機器/哪個庫/庫內什麼路徑」,
|
||||
// **不再是 `../`**(那是卡片檔相對原檔的內部結構,使用者拿著它走不到任何地方)。
|
||||
if !strings.Contains(doc, "`test@Machine \u203a test-lib \u203a 測試文件.md`") {
|
||||
t.Fatalf("出處沒寫成「機器 › 庫 › 庫內路徑」:\n%s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, "`test-lib/測試文件.md`"+triSep+"提及") {
|
||||
t.Fatalf("出處三元組主詞不是庫內定位:\n%s", doc)
|
||||
}
|
||||
if strings.Contains(doc, "../") {
|
||||
t.Fatalf("出處仍帶內部相對路徑 `../`:\n%s", doc)
|
||||
}
|
||||
ca := mustRead(t, filepath.Join(root, ".wiki", "概念甲.md"))
|
||||
for _, want := range []string{
|
||||
@@ -84,7 +91,7 @@ func TestBuildWikiDoc_EdgesAreBidirectional(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := "# 測試文件\n內文"
|
||||
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
|
||||
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cb := mustRead(t, filepath.Join(root, ".wiki", "概念乙.md"))
|
||||
@@ -100,7 +107,7 @@ func TestBuildWikiDoc_LinksClosedAndIndexishFixed(t *testing.T) {
|
||||
mustWrite(t, filepath.Join(root, "文件.md"), src)
|
||||
ex := wsExtract()
|
||||
ex.Points = []string{"[[概念甲]] — 這行是 index 式,要被改寫", "這裡提到 [[不存在的卡]] 應拆殼"}
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, ex, wsOrigin("文件.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := mustRead(t, filepath.Join(root, ".wiki", "文件.md"))
|
||||
@@ -117,7 +124,7 @@ func TestMarkDocNoConcept_ListedOnIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "發票.txt"), "金額 100")
|
||||
ex := &DocExtract{NoConcept: true, Reason: "純紀錄(發票),沒有可獨立成立的判斷"}
|
||||
cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsNow)
|
||||
cards, err := BuildWikiDoc(root, "發票.txt", "金額 100", ex, wsOrigin("發票.txt"), wsNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,7 +146,7 @@ func TestBuildWikiDoc_NestedNodeAndAncestors(t *testing.T) {
|
||||
src := "# 週會決議\n內容"
|
||||
mustWrite(t, filepath.Join(root, rel, "週會.md"), src)
|
||||
|
||||
if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "專案/會議/週會.md", src, wsExtract(), wsOrigin("專案/會議/週會.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "專案", "會議", ".wiki", "週會決議.md")); err != nil {
|
||||
@@ -173,14 +180,14 @@ func TestBuildWikiDoc_ReextractCleansOldCards(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := "# 文件\n內文"
|
||||
mustWrite(t, filepath.Join(root, "文件.md"), src)
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二輪:概念換名
|
||||
ex2 := wsExtract()
|
||||
ex2.Concepts = []WikiConcept{{Name: "全新概念", Gloss: "新的一句話", Summary: "新摘要", Points: []string{"新判斷"}}}
|
||||
ex2.Points = []string{"重點換成 [[全新概念]] 了"}
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsNow.Add(24*time.Hour)); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, ex2, wsOrigin("文件.md"), wsNow.Add(24*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".wiki", "概念甲.md")); !os.IsNotExist(err) {
|
||||
@@ -201,7 +208,7 @@ func TestRemoveWikiDoc_CleansEverything(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := "# 文件\n內文"
|
||||
mustWrite(t, filepath.Join(root, "文件.md"), src)
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "文件.md", src, wsExtract(), wsOrigin("文件.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RemoveWikiDoc(root, "文件.md"); err != nil {
|
||||
@@ -230,10 +237,10 @@ func TestBuildWikiDoc_SameConceptFromTwoDocsDisambiguated(t *testing.T) {
|
||||
Points: []string{"重點連到 [[迭代]]"},
|
||||
Concepts: []WikiConcept{{Name: "迭代", Gloss: "同名概念", Summary: "摘要", Points: []string{"判斷"}}}}
|
||||
}
|
||||
if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "a.md", srcA, ex(), wsOrigin("a.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "b.md", srcB, ex(), wsOrigin("b.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := mustRead(t, filepath.Join(root, ".wiki", "迭代.md"))
|
||||
@@ -255,7 +262,7 @@ func TestNodeIndex_FiveFieldsPerDocLine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := "# 測試文件\n內文"
|
||||
mustWrite(t, filepath.Join(root, "測試文件.md"), src)
|
||||
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsNow); err != nil {
|
||||
if _, err := BuildWikiDoc(root, "測試文件.md", src, wsExtract(), wsOrigin("測試文件.md"), wsNow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := mustRead(t, filepath.Join(root, ".wiki", "00-INDEX.md"))
|
||||
|
||||
Reference in New Issue
Block a user