6 Commits

Author SHA1 Message Date
Leo e3f1d0177d sync: collector/ 同步自 inkstone/arcrun-rag@f2a1121(桌面小幫手 0.18.39) 2026-08-27 12:54:08 +08:00
Leo 4d966b7922 sync: collector/ 同步自 inkstone/arcrun-rag@c6cf717(桌面小幫手 0.18.38) 2026-08-26 21:04:03 +08:00
Leo a1786135d5 sync: collector/ 同步自 inkstone/arcrun-rag@1edbbb2(桌面小幫手 0.18.37) 2026-08-26 17:22:08 +08:00
Leo d028a66625 sync: collector/ 同步自 inkstone/arcrun-rag@45b74a4(桌面小幫手 0.18.36) 2026-08-24 18:26:55 +08:00
Leo f1658fd2ad sync: collector/ 同步自 inkstone/arcrun-rag@a39cea2(桌面小幫手 0.18.34) 2026-08-20 11:26:28 +08:00
Leo 969c3f2bcf revert: 還原被第一次同步蓋掉的三個檔(D95,inkstone/InkStoneCo#40)
2026-08-18 出貨線第一次真的把 arcrun-rag 的 collector/ 同步過來,
盲目鏡射蓋掉/刪掉了三個**本來就該只屬於這個 repo** 的檔:

  .gitignore           21 行的「不要再長回來」閘 → 被換成 arcrun-rag 那份的一行
                       🔴 最嚴重:那份清單正是擋 43 MB 建置產物的東西,
                          它被蓋掉之後,下一趟同步就會把產物搬進來
  README.md            介紹這個獨立 repo 的 → 被換成 arcrun-rag 子目錄的 README
  cmd/collector/main.go 正常源碼,被誤判成建置產物刪掉
                       (.gitignore 的 `collector` 一行是指根目錄那顆執行檔,
                         但無斜線樣式在任何層級都命中 ⇒ 連 cmd/collector/ 也中)

出貨線那側已修(installer/scripts/line-source-repo.mjs):
  · destOwned:目的 repo 的門面檔不覆蓋、不刪除,且同步前檢查它們還在不在
  · 已被目的 repo 追蹤的檔案不算「被忽略」(git 自己的語意)⇒ main.go 不再被誤刪

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:33:34 +08:00
41 changed files with 6631 additions and 77 deletions
+21
View File
@@ -1 +1,22 @@
node_modules/
# ── 編譯產物不進版控(D95,2026-08-18)────────────────────────────────────
# 這個 repo 從 arcrun-rag 的 collector/ 抽出來時,歷史裡有 46 個 >1MB 的編譯產物
# arcrun-tray 的 .app、各版 dmg、下載回來的 WebView2 安裝檔…),總計 626 MB。
# 抽取時已把它們從歷史裡剔除 ⇒ 這個 repo 全部歷史只剩 8 MB。
#
# 🔴 這一份清單是「不要再長回來」的那道閘:出貨線每次把 arcrun-rag 的 collector/
# 同步過來時,`git add -A` 會照這份跳過它們(見 installer/scripts/line-source-repo.mjs)。
# ⚠️ arcrun-rag 那邊**現在還追蹤著這四個檔**——不在這一輪拿掉,是因為
# `daemon-version.py --source-state` 的指紋把它們算在內,改動會讓 daemon-freshness
# 那道閘擋下出貨。拆它要跟戳版帳本一起處理,另立一輪(票:inkstone/InkStoneCo#40)。
collector
cmd/arcrun-app/arcrun-app
cmd/arcrun-app/dist/
cmd/arcrun-app/dist-msix/
cmd/arcrun-app/build/bin/
cmd/arcrun-app/build/windows/installer/tmp/
cmd/arcrun-app/frontend/dist/
cmd/arcrun-app/frontend/node_modules/
cmd/arcrun-app/frontend/wailsjs/
cmd/**/*.app
+41
View File
@@ -47,6 +47,47 @@
---
## 下一版(未發佈)
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
## 0.18.382026-08-26
- 🔴 **每一層子資料夾都看得到,不是只有第一層**:以前你只看得到自己選的那個資料夾本身,裡面到底有哪些子資料夾、哪幾層、哪些東西真的上去了,完全看不到。現在**桌面小幫手與知識庫網頁兩邊都攤得開**:小幫手裡點進那個知識庫,資料夾右邊多一顆「資料夾結構」,按下去就是一棵像檔案總管那樣、一列一列往下縮排的樹,點資料夾展開、再點收合,也可以一鍵全部展開/全部收合。
- **小幫手看得到,不必等雲端、也不必連線**:這棵樹是你這台電腦自己算出來的。就算網路斷了、或今天的 AI 額度用完了,它照樣打得開——那正是你最想知道「到底同步到哪了」的時候。
- **每一層都告訴你「幾份、上去了幾份」**:每個資料夾旁邊會標「已同步 / 總共」。兩個數字不一樣也會告訴你差在哪裡——還讀不了的格式、不在收檔範圍的檔案,還是還在處理中。
- **整個沒走進去的資料夾,不會假裝成 0 / 0**:被跳過的資料夾會直接寫「整個資料夾未收」加上原因,而不是給你一個我們自己編的數字。
## 0.18.372026-08-26
- 🔴 **雲端被清空過的檔案,小幫手現在會自己發現並補送**:以前小幫手送成功一次就會在本機蓋一個「已送」的章,而那個章**永遠不會過期**。萬一雲端被重裝或清空,那批檔案就**再也不會被送上去**,而且沒有任何地方會跟你說——你只會發現「檔案明明在資料夾裡,AI 卻查不到」,而且查不出為什麼。現在它會跟雲端對一次帳,發現那邊沒有就重新送。
- **重跑第二次不會又全部送一遍**:對完帳之後它分得出「雲端真的沒有」與「我上次送過了」,不會把你的 AI 額度燒掉。
- **補送的時候看得到**:不會靜悄悄跑完。
## 0.18.362026-08-24
- 🔴 **移除資料夾時,可以順便把我們放進去的隱藏檔案一起清掉**:小幫手整理你的資料夾時,會在**每一層**放一個隱藏的整理稿目錄(`.wiki``.arcrun-rag`)。以前你把資料夾從清單移除之後,這些東西**全部留在原地**——隱藏的、散在幾十層裡,你自己幾乎刪不乾淨。現在移除的對話框多了一個勾選框「順便把 Arcrun RAG 放在這個資料夾裡的檔案清掉」,勾起來會**先把確切要刪哪些列給你看**,你看過再按確定。
- **只刪我們自己建的,你的檔案一個都不會碰**:每一筆都要有我們自己寫下的依據(我們放的說明檔、我們的整理帳本、我們的檔名前綴)才會刪;只要有一個東西認不出來,那整個目錄就原封不動留著,並且在清單上告訴你留了什麼、為什麼留。
- **這幾種情況我們刻意不刪**:你那個資料夾裡如果還有別的資料夾也在同步清單裡,它底下的東西不碰;以前從你資料夾搬進暫存區的檔案不碰(那些是你的);捷徑(symlink)不碰。
- 不勾這個框的話,行為跟以前一模一樣:硬碟上一個檔都不會少。
## 0.18.352026-08-24
- 🔴 **桌面小幫手多了「App 界面」:一打開就是九宮格,點一下就用得到**:以前小幫手只做一件事——把資料夾整理進知識庫;要用知識庫上的 App(例如寫一則筆記),得另外開瀏覽器登入雲端。現在小幫手最上面多了一頁「App 界面」,裡面是**你連的那個知識庫實際裝了的 App**,一個 icon 一個 App,點下去就在小幫手裡打開,不用再開瀏覽器。
- 這批 App **跟你在知識庫網頁上看到的是同一批**:清單直接跟知識庫要,桌面上不另存一份,所以你在任何一台電腦看到的都一樣;在雲端裝了新 App,回到這裡按「重新整理」就會出現。
- 沒有自己畫面的 App 也點得開:會列出它能做的事,一條一顆「現在執行」。
- 連了不只一個知識庫的話,「App 界面」上方可以切換要看哪一個。
- 還沒連上知識庫、或這個知識庫一個 App 都沒裝,畫面會直接說是哪一種情況,不會只給你一片空白。
- **原本的功能一項都沒少**:同步狀態與「立刻同步」搬到了每一頁都看得到的最上方,首頁、各知識庫、AI 設定、版本與更新都在側邊欄原來的位置。
## 0.18.342026-08-20
- 🔴 **雲端現在看得到你資料夾的完整層次,不再只有第一層**:以前不管你的資料夾疊了幾層,雲端只認得最外面那一層——`專案/教育部標案/會議紀錄` 這種放在深處的資料夾,在知識庫裡完全不存在,只會以檔案路徑的一段文字出現。現在**每一層資料夾都有自己的一格**,而且**每一格都連著它的上一層**,所以你可以像在檔案總管裡一樣,從最外層一路點進最深的那個資料夾。每一格還會告訴你這一層直接放了幾份文件、連同底下的子資料夾一共幾份。
- 這些索引**不經 AI 萃取**(本機掃一遍就有答案),所以不吃你的每日額度,額度用完的時候照樣會更新。
- 空的資料夾目前還不會出現(要等掃描開始記錄資料夾本身);已經同步過的內容不受影響。
- 🔑 **同一份檔同步到兩台電腦,問答的出處不再只看得到一台**:上一版已經讓每份原稿記住它來自哪一台機器,但 AI 回答時還是會把同名的檔案當成同一份而只列出其中一台。現在內容還是只留一份(不會重複),但出處那一格會把它存在的每一台都列出來。
## 0.18.332026-08-18
- 🔴 **現在看得出每份原稿在哪一台電腦上**:以前知識庫只記得檔案的相對路徑,所以你在兩台電腦、或電腦加 NAS 上放了同名的檔,系統分不出誰是誰——問答的出處只會顯示其中一台,另一台就這樣消失了。現在每份原稿都會記住它來自哪一台機器,預設用「你的帳號@電腦名稱」,你可以改成自己看得懂的稱呼(例如「教育部 Leo 的 Mac」)。既有的資料不受影響,那一格會誠實地空著,不會憑空編一個機器名給你。
+24 -9
View File
@@ -1,15 +1,30 @@
# collector
# arcrun-collector — 桌面小幫手(Arcrun RAG Desktop
> 📦 **桌面小幫手(`0.18.x`)的版本發佈住在 [`inkstone/arcrun-collector`](https://git.uncle6.me/inkstone/arcrun-collector/releases),不在本 repo 的版本發佈頁**D952026-08-18`inkstone/InkStoneCo#40`)。
> **這個 repo 是「桌面小幫手」的家:原始碼、版本說明、每一版的成品,全部在這裡。**
>
> 本 repo`arcrun-rag`)的版本發佈頁只放**雲端引擎 `1.4.x`**。兩條線是兩個產品,
> 疊在同一條歷史上就答不出「最新版是哪一個」——leo:「**把一個差很多的東西塞進去別人的歷史裡**」
> 出貨線由 `installer/ship.targets.json` 的 `releaseRecord.lineRepos` 決定每條線發到哪,
> **宣告缺一條就不准開跑**`installer/scripts/line-source-repo.mjs`)。
> - **要下載桌面小幫手** → 右邊的「版本發布 / Releases」,最新一版就是最上面那筆。
> 每一筆都掛著當版的三個成品:macOS 的 `.dmg`、Windows 的 `.exe` 與 `.msix`
> - **這一版改了什麼** → 同一筆 release 的內文,原稿是 [`CHANGELOG.md`](CHANGELOG.md)。
> - **版本號長什麼樣** → `0.18.x`[`DAEMON_LINE`](DAEMON_LINE) 宣告的那條線)。
> 雲端引擎是 `1.4.x`,**是另一個產品、另一個 repo**(見下)。
>
> ⚠️ 源碼此刻**兩邊都有**:這個目錄仍是編輯的地方,出貨時同步到 `arcrun-collector`。
> 把本目錄整個下架是另一輪的事(要動 20 幾支讀 `collector/` 的腳本)。
> 舊的 `0.18.30``0.18.33` 兩筆還留在本 repo 的版本發佈頁,等新位置站穩後再由總管決定何時拆。
> ## 跟 arcrun-rag 的關係
>
> [`inkstone/arcrun-rag`](https://git.uncle6.me/inkstone/arcrun-rag) 是**雲端引擎**`1.4.x`)——
> 跑在使用者自己 Cloudflare 帳號上的那些 worker。桌面小幫手會連上它,但兩者是兩個產品、
> 兩條版本線、兩份版本說明。
>
> 🔴 **為什麼分開**D95leo 2026-08-18):在此之前兩個產品的版本發布疊在 arcrun-rag
> 同一條歷史上(`0.18.33`、`0.18.30` 跟 `1.4.49`、`1.4.48` 並排),而且出貨線每跑一次就多疊一筆。
> leo 原話:「**我強調了不要扭曲,這就是扭曲,把一個差很多的東西塞進去別人的歷史裡。**」
> 兩個產品共用一條版本歷史,「最新版是哪一個」就沒有答案。
>
> 📌 過渡狀態(誠實記著):原始碼目前仍同時存在於 `arcrun-rag` 的 `collector/`
> 由出貨線每次出貨同步過來。**把 `collector/` 從 arcrun-rag 拆掉**要動 20 幾支讀那個路徑的
> 腳本,另立一輪處理(票:`inkstone/InkStoneCo#40`)。
> 在那之前,**發佈的落點以本 repo 為準**。
---
> ✂️ **legacy Node 版(Gitea push 鏈)已於 2026-07-19 刪除**SDD `ingest-hash-trigger` task 4
> 「Gitea webhook 接收端程式碼與路由已刪除(git 記錄可查)」)。`index.js`/`transform.js`/
+543
View File
@@ -0,0 +1,543 @@
// cleanup.go — 斷連時把 daemon 自己寫進使用者資料夾的東西收回來
// arcrun-rag#138leo 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+$`)
// WorkspaceIgnoreBodyForTestWikiIgnoreBodyForTest 讓**別的 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
}
+95
View File
@@ -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
View File
@@ -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("使用者的檔被動到了:%sbefore=%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
}
+284
View File
@@ -0,0 +1,284 @@
// cloud_audit.go — 本機帳本 ↔ 雲端索引對帳(`inkstone/arcrun-rag#140`)。
//
// 病(`inkstone/Arcrun#165` 斷點一,2026-08-26 實測):
//
// 本機 manifest 蓋的「已送成功」章**永遠不會過期**。雲端在 2026-08-14 被重裝/清空之後,
// 那些檔案的 content_hash 沒變 ⇒ Scan() 不產生事件 ⇒ **永遠不會重送**,
// 而且**沒有任何地方會說話**。使用者看到的是「檔案明明在資料夾裡,AI 卻查不到」。
// 實據:youlinhsieh-test1 的 8 個檔 ingested_at 落在 07-29~08-06、零 fail_count
// 雲端 D1 的 MIN(created_at) 卻是 08-14 20:27 ⇒ 分界線乾淨得可怕。
//
// 解的形狀(**不是把兩邊砍掉重來**,那是紅線):
//
// 章不再是「我送過了」,而是「我送過了**而且雲端現在還有**」。
// 後半句本機答不出來,只能去問雲端 ⇒ 這支檔就是那個問句。
//
// 為什麼問得起(不必等雲端先改):cypher-executor 早就有 `/kbdb/entries` proxy
// 認證用的正是 daemon 本來就帶著的 `X-Arcrun-API-Key`routes/kbdb-proxy.ts
// owner_id 由 server 端強制注入,租戶跨不過去)。⇒ **零雲端改動、零新憑證。**
//
// 三條自我約束(對應票上的紅線):
// - **不刪任何東西**:對帳只會把本機的章拔掉(讓它重走既有的送件路),
// 不碰雲端 entry、不碰 .wiki 卡、不碰使用者的檔。
// - **不做全量重送**:一次只問 cloudAuditBatch 個檔,且同一個檔
// cloudAuditRecheckInterval 內只問一次;問到「雲端沒有」才拔章。
// - **不會重送兩次**:拔過章的檔在 cloudAuditRepairGrace 內不再被拔第二次
// (雲端 ingest 是非同步的,剛送出去那幾秒查不到是正常的——
// 沒有這道閘,它會變成一個每輪重送、把額度燒光的迴圈,
// 那就是「把一個 bug 換成另一個」)。
package collector
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// cloudAuditRecheckInterval=同一個檔多久重新對一次帳。
// 24 小時的理由:這是「雲端被清空」這種罕見事故的偵測延遲上限,
// 而使用者按「立刻同步」(ForceSync)時本輪一律重對,不必等它。
const cloudAuditRecheckInterval = 24 * time.Hour
// cloudAuditRepairGrace=同一個檔拔過章之後,多久內不准再拔第二次。
//
// 🔴 這是「重跑第二次不可以又全部重送一遍」(#140 驗收條件 5)的那道閘。
// 雲端 rag_ingest_card 是觸發式的,卡片不會在 POST 回來的那一瞬間就查得到;
// 沒有這個窗口,對帳會在下一輪又判它「不在」⇒ 重送 ⇒ 無限迴圈 ⇒ 燒光額度。
const cloudAuditRepairGrace = 24 * time.Hour
// cloudAuditBatch=單輪最多問幾個檔。
// 與 DefaultMaxEventsPerRun 同一個精神:巨量積壓(實據 27,164 檔)不該一輪湧完。
const cloudAuditBatch = 20
// cloudAuditFolderInterval=同一個資料夾兩次對帳批次的最小間隔。
// daemon 預設 5 秒一輪,沒有這道節流就是每 5 秒 20 個請求。
const cloudAuditFolderInterval = 60 * time.Second
// cloudAuditProbePace=批次內每個請求之間的間隔。
// 比 directPaceInterval700ms)短很多是因為這是 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_urlapi_key 是空的)")
}
q := url.Values{}
q.Set("source", "kb://"+relPath+"#0")
q.Set("entry_type", "block")
q.Set("limit", "1")
q.Set("offset", "1000000") // 只要 total,不要把卡片內文整包拉下來
if strings.TrimSpace(library) != "" {
q.Set("library", library)
}
req, err := http.NewRequest(http.MethodGet, base+"/kbdb/entries?"+q.Encode(), nil)
if err != nil {
return false, false, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Arcrun-API-Key", cfg.APIKey)
resp, err := cloudAuditHTTP.Do(req)
if err != nil {
return false, false, err
}
defer resp.Body.Close()
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, false, fmt.Errorf("HTTP %d%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if rerr != nil {
return false, false, rerr
}
var payload struct {
Success *bool `json:"success"`
Total *int `json:"total"`
}
if jerr := json.Unmarshal(body, &payload); jerr != nil {
return false, false, fmt.Errorf("雲端回應不是 JSON%s", strings.TrimSpace(string(body)))
}
// 🔴 total 缺席時回 ok=false 而不是「total=0」。少一個欄位可能是端點換了形狀,
// 把它讀成「雲端沒有這個檔」就會全部重送——寧可什麼都不做。
if payload.Total == nil || (payload.Success != nil && !*payload.Success) {
return false, false, fmt.Errorf("雲端回應少了 total 欄位:%s", strings.TrimSpace(string(body)))
}
return *payload.Total > 0, true, nil
}
// auditCandidates 挑出這一輪要問的檔:蓋過章、且太久沒對過帳的,最久沒對的排前面。
//
// 三種**不問**(每一種都對應一個會咬人的情境):
// - 沒蓋過章(IngestedHash 空):雲端本來就不該有它,Scan() 自己會重送。
// - NoCloudCard:萃取判定「無可萃取概念」⇒ 本來就沒送過卡上雲。
// 不排除它的話,對帳每次都會說「雲端沒有」⇒ 每天重萃一次,永遠停不下來。
// - 剛拔過章還在 grace 內:見 cloudAuditRepairGrace。
func auditCandidates(m *Manifest, now int64, force bool) []string {
recheck := int64(cloudAuditRecheckInterval.Seconds())
grace := int64(cloudAuditRepairGrace.Seconds())
var out []string
for p, e := range m.Entries {
if e == nil || strings.TrimSpace(e.IngestedHash) == "" || e.NoCloudCard {
continue
}
if e.CloudMissingAt > 0 && now-e.CloudMissingAt < grace {
continue
}
if !force && e.CloudCheckedAt > 0 && now-e.CloudCheckedAt < recheck {
continue
}
out = append(out, p)
}
// 最久沒對帳的先問(0=從沒問過,天然排最前);同分時按路徑排序,讓同一份輸入
// 永遠得到同一個輸出(可測)。
sort.Slice(out, func(i, j int) bool {
ai, aj := m.Entries[out[i]].CloudCheckedAt, m.Entries[out[j]].CloudCheckedAt
if ai != aj {
return ai < aj
}
return out[i] < out[j]
})
if len(out) > cloudAuditBatch {
out = out[:cloudAuditBatch]
}
return out
}
// auditResult=一輪對帳的結果,給呼叫端組人話與記帳用。
type auditResult struct {
Checked int // 這輪真的問了幾個檔
Voided int // 其中幾個雲端查不到、章被拔掉(=已排進重送佇列)
Err string // 對帳本身失敗的真因(原文,不改寫——leo 2026-08-06 原則)
}
// auditCloudLedger 對一個監看根跑一輪對帳。回 nil=這輪不必對(節流中/沒有候選)。
//
// 🔴 它**只做一件事**:把「雲端已經沒有了」的章拔掉。
// 拔完之後,接下來的 Scan() 會因為 `orig[p].IngestedHash == ""` 自然補一發 added
// (scan.go 步驟 4「上輪偵測過但 ingest 未成功 → 重試」),
// 於是重送走的是**既有的**萃取路:既有的單輪上限、既有的失敗退避、既有的額度冷卻
// 全部照舊生效。⇒ 沒有第二條送件路,也沒有第二套節流要維護。
func auditCloudLedger(cfg *DirectConfig, absRoot string, m *Manifest, dryRun bool, now time.Time) *auditResult {
if dryRun {
return nil
}
nowUnix := now.Unix()
if !cfg.ForceSync && m.CloudAuditAt > 0 &&
nowUnix-m.CloudAuditAt < int64(cloudAuditFolderInterval.Seconds()) {
return nil // 節流:同一個資料夾一分鐘內不重問
}
cands := auditCandidates(m, nowUnix, cfg.ForceSync)
if len(cands) == 0 {
// 沒有候選也要記時間,否則每輪都要重走一次上面那個迴圈。
m.CloudAuditAt = nowUnix
return nil
}
lib := cfg.libraryFor(absRoot)
res := &auditResult{}
for _, p := range cands {
e := m.Entries[p]
if e == nil {
continue
}
present, ok, err := probeCloudCard(cfg, lib, p)
if !ok {
// 問不到就整批停手:連不上時繼續問剩下的 19 個只是重複同一個錯誤。
// **絕不把「沒查成」當成「雲端沒有」**(那會讓一次網路抖動變成全量重送)。
if err != nil {
res.Err = err.Error()
}
break
}
res.Checked++
e.CloudCheckedAt = nowUnix
if present {
continue
}
// 雲端真的沒有 ⇒ 這個章是假的,拔掉。**不動使用者的任何東西。**
e.IngestedHash = ""
e.CloudMissingAt = nowUnix
// 失敗退避是「這個檔壞掉」的病歷,跟「雲端被清空」無關——留著會讓補送
// 卡在上一次的退避階梯裡(實測那 8 個檔 fail_count 全是 0,但別的資料夾不一定)。
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
res.Voided++
pace2(cloudAuditProbePace)
}
m.CloudAuditAt = nowUnix
if res.Checked == 0 && res.Err == "" {
return nil
}
return res
}
// ResyncSummary 從 manifest 現況算出「補送」這件事現在講到哪裡」。
//
// 為什麼是**從 manifest 重算**而不是記一個計數器:計數器是「本輪做了幾件事」,
// 沒事做的那輪就會歸零——2026-08-05 leo 實撞的「明明做完了畫面卻永遠寫等待中」
// 就是那個形狀。這裡算的是**現況**(還有幾份沒補回來),補完自然歸零、不必有人去清。
func ResyncSummary(m *Manifest, now time.Time) (pending, repaired int) {
nowUnix := now.Unix()
fresh := int64(cloudAuditRepairGrace.Seconds())
for _, e := range m.Entries {
if e == nil || e.CloudMissingAt == 0 {
continue
}
if strings.TrimSpace(e.IngestedHash) == "" {
pending++ // 章拔掉了、還沒補送成功
} else if e.IngestedAt >= e.CloudMissingAt && nowUnix-e.IngestedAt < fresh {
repaired++ // 剛補送成功(只講最近一天的,不然這行會永遠掛在畫面上)
}
}
return pending, repaired
}
// resyncNote 把上面兩個數字翻成使用者看得懂的一句話。
//
// leo 2026-08-06 的原則(「別人的錯誤一律要顯示給用戶看,不然就會變成我的錯誤」)
// 在這裡的形狀是:**不能靜悄悄地重送**。使用者只要看到「有 8 份在補送」,
// 就知道畫面上的數字為什麼會動、也知道不是自己弄壞的。
func resyncNote(pending, repaired int, lastErr string) string {
switch {
case pending > 0 && repaired > 0:
return fmt.Sprintf("雲端上找不到先前送過的檔案(知識庫可能重裝過),正在自動補送:已補回 %d 份,還有 %d 份排隊中。", repaired, pending)
case pending > 0:
return fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(知識庫可能重裝過),已排進佇列自動補送,你不必做任何事。", pending)
case repaired > 0:
return fmt.Sprintf("已把雲端上遺失的 %d 份檔案補送回知識庫。", repaired)
case lastErr != "":
return "暫時無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試)。"
}
return ""
}
// pace2=可指定長度的節流(directPaceInterval 那支是萃取路專用的固定 700ms)。
// 拉成獨立函式是為了讓測試能把它調成 0。
var pace2 = func(d time.Duration) {
if d > 0 {
time.Sleep(d)
}
}
+390
View File
@@ -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("沒有連線資訊時不該宣稱查得到答案")
}
}
+36 -19
View File
@@ -1,20 +1,27 @@
{
"_algo": 4,
"version": "0.18.33",
"fingerprint": "7ba0f6dd2d080925",
"version": "0.18.38",
"fingerprint": "7add5da65c671548",
"files": {
".gitignore": "4d56952b0fb13bf8f9b6c13a6d4c34a075bac3af447636a1df4335d7576e2f97",
"CHANGELOG.md": "5f2cd870b97a25fee01f5d2b4e7ac7c9afdd091c95ca5725ff7401823b651967",
"CHANGELOG.md": "9dd02f82a4f5417520fb20642aec004aa25c241e404621aacf6f37e549602b2c",
"DAEMON_LINE": "d5019abbdc8a5f2919e9e3510391891cd7fbdf0765bf16ec83caa779f370116d",
"README.md": "3329fbc8f4ca5e966f26c5745117bf2ddc65603b4ed78060835270dc39132bfc",
"README.md": "9d92cac236b20a0b183eea3e7f5e39ad492f05192c4ea602eb11c3d09967327f",
"check-standalone.sh": "65fbce096326791c2f103a51e76d0ad79e9e510e2a7700b49ac2d2d81f73c853",
"cleanup.go": "59a45672f7f73d73833685846148ba20f71d5b3f42340be5a74a10437be267fc",
"cleanup_cli.go": "5dc41aac30f39757658d7bc95ab727ecc799b8242fd752edcedbb8423935fbf0",
"cleanup_test.go": "23d627a12ec476bb8898847185a1464cb78e2186278299ff13c84a3d54af63c0",
"cloud_audit.go": "a51f5c6eafaa75ce282b307e8dd2986f0b316a3c24282bed0856002819dd86e2",
"cloud_audit_test.go": "1790837e8a5726d62258436937f9c474f7abd03e6229832fdbd40f8cd7aa756a",
"cloud_latest.go": "c2ad7f87bbd55247540eb924ac3be5deadbc4488b504770a5127a01c748f0e0f",
"cloud_latest_test.go": "703963b65da91ae0c4638b9ad676ca7f12a474c635e903a5d988e4980b3888aa",
"cloud_version.go": "eebc2552e09e199928e82607bebf90daef36b5721ec9b8a098304c10dbec46f9",
"cloud_version_test.go": "66c004de1f0a3958851f22a22367065f13b3381ac7d936ae54d812c26a0b9fb6",
"cmd/arcrun-app/.gitignore": "7502ba597654ebb3463535bdfb51c271abad666a67c7ac8ab36deca1a72615f8",
"cmd/arcrun-app/README.md": "9ea849308b5e578d9a43b73fc3d72637d21428eca9c96a448d31078e8627bcb4",
"cmd/arcrun-app/app.go": "4c65f895c68f82a84d633e6e6d05fbabb86e51632f9ae3d693e39a67ca2b981c",
"cmd/arcrun-app/README.md": "23e53fdf53fcd96b6d8d7a121eb91d3242d11b69d655770473b745c49bb387b9",
"cmd/arcrun-app/app.go": "274129ed77c4f4194f6b2007cb47e4ffe536b15b6450fe89b14fbb31e6a16395",
"cmd/arcrun-app/apps.go": "0df6da9d55adbdec500fbb01005a305287d1dbf796b7e463f2b2f222626b4c96",
"cmd/arcrun-app/apps_test.go": "0e2f137f8b49f421e869ab3192ca12c8bb756029d8730487ccc44733aaf7f96c",
"cmd/arcrun-app/arcrun-app": "e2bdf54a15d69860df05820274a80d7497b7d62a4e8ba219f72f30b543456b79",
"cmd/arcrun-app/assets/store/README.md": "a9028a727947c5038ca938c664b18e4851ae3bf0e6d8838c82aedea43d02ded2",
"cmd/arcrun-app/assets/store/Square150x150Logo.png": "27942d943096ba23f036b030726e905f6df6123428cd7d8ec687ce5b11e42db1",
@@ -48,9 +55,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",
@@ -79,8 +88,8 @@
"cmd/arcrun-app/frontend/src/assets/fonts/OFL.txt": "8656c2a27357b3140047a39b27e3cd928256544ad8a004ef77087f761358208c",
"cmd/arcrun-app/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2": "06f3af3fe52542d40ad9bc14ec03e04deaabd09ec369221cc8f536db1c72bf55",
"cmd/arcrun-app/frontend/src/assets/images/logo-universal.png": "157a874acd3363634747287c0b01034e9f1eec838a4cf39827dfea789e730494",
"cmd/arcrun-app/frontend/src/main.js": "75bec60d9af627c990b2f2007ffd70e28e33a4a728522099777e028cd670e385",
"cmd/arcrun-app/frontend/src/style.css": "08e890f3f0d7f603249175d5415f64c43370d08bbd30b66c7ccb39a58e399169",
"cmd/arcrun-app/frontend/src/main.js": "277390ab8bf4114c797aa32b2cdae151e86a63681c612c424e73c308c446234e",
"cmd/arcrun-app/frontend/src/style.css": "4fb87b139100b826c02c71a69599da2c4e8b93d3f7855c2a9b10e6677fecfbb2",
"cmd/arcrun-app/gen-icons.py": "df5b3b33e500fc564badfc8bd2a9ca6a43f7d7e3b31111c41e3b322137c4c96c",
"cmd/arcrun-app/go.mod": "8b9e57bcfd44886d1ec06480e033af706ff3fed470786c57074900dd48ecf86f",
"cmd/arcrun-app/go.sum": "bc4453654c203e5af251c5a3061616cb0ceeb1b6a78a02ace2ca85524d217256",
@@ -88,13 +97,15 @@
"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",
@@ -111,11 +122,11 @@
"convert_table_test.go": "d0371b7566ef3152f9dd42f9f990e0dffa1c50a0c8e874a28415fa2e4c188394",
"convert_test.go": "04f3fa30d1be5f910c0e0be3308ced2963191ab030a9eaccd986ef581fcd4e18",
"convert_wiring_test.go": "09e97bf32ace245b55acc7d65ee5c0bb7fd84f8fb1aab61f603f9e9d18b22f13",
"direct.go": "27fed72987d3f5984cae939f0b09de069a9f13f97e24e91361b60399e1bdb5d8",
"direct_extract_test.go": "d3465c1b5953858ae780d192a2cf515d452f48eee3ff18a6345ce64320debbbb",
"direct_multi_test.go": "d0aa211d90aaaa9d7dbe2b04f1aaa5989e91e91b037144cc0f4e0fd068f43c2b",
"direct.go": "70ff75a04cdfff09453ff858d3e468e110057e555c402e5969221072fefe1a41",
"direct_extract_test.go": "317d0057d2d368fabc406351a7477f72e074d2b8c8459b1dc021febdf16902f4",
"direct_multi_test.go": "a76593fef26231e18b7402f6f7376624d2471f8a719b3ce08fc4a0f5a3d3cf1e",
"direct_pacing.go": "2690b2fff858250028886bc3cd7793be54b4d4a03ddca41485476cb04b79f7c3",
"direct_pacing_test.go": "c21298922866eb9dabc6a87d9d466e08e1001db52841c86f3e720dacfe955bd8",
"direct_pacing_test.go": "ba5c140082931a50c3728078502803c654c2fb9dacecda679f8f40e6ab61a0c5",
"direct_quota_test.go": "0d4747db434fd10f367ff88c8253263e2f2ce3559cd767de2f0b2c77bdf7a080",
"direct_retire_test.go": "fbff728362313c546c1fc01cc74094672e22a8171d4bc0329ce1b3d118c82b3c",
"direct_sync_now_test.go": "dc7a6e3d5646a470e889d30f0224cdb86be832b8cf85ffef8629e42ec577622b",
@@ -126,7 +137,13 @@
"extract_test.go": "63560ecca929c6507d93b3f52988bb7ce2cd6137140eb373c520eac5ee052c76",
"extract_workersai.go": "ea77c25d2a14d905e3c7f3a7caaa3dff2fa5ceb71c98c772a258ef0e770106f3",
"extract_workersai_test.go": "188822f4c44ee5bf3b1400e1475af314ad831296538a68c5e1006341ba63765e",
"folderindex.go": "f3dd186fd7e882ff293b3f8aa6b2582b018290ee5f14bbdc02a7ac131834fd69",
"folderindex_helpers_test.go": "2cf17b70c396225f728e8e160cfc75d7bdc76ac1ce8e574d1450b020050c0734",
"folderindex_test.go": "a5319eb1e92473cb5c32d3beddb7889997556227824de300dd39953b43beab70",
"foldershape.go": "b1b237f5674674efdc9bdea849b63c571a014e64bec33af42fb7c4769fa7582b",
"foldertree.go": "8b3de4fde32e538b3a6a9440f6fb89e5737391a2499668b961a933509b36fa17",
"foldertree_local_test.go": "ce919dc2a21ec96716e3d43a7f493367346e212243b4ab22c2d960fc26408f3a",
"foldertree_test.go": "f8b8cdf1ef9d7d1c80cabb086fc9b4f2162b91a47d6d78bde2632b07672c0b88",
"go.mod": "05dacc224af2a6797db86689435f83537efb433ffba521467b95d1099f5e12db",
"go.sum": "a9c92957ca368462aab32a82a6f51c9586ea07478a7967012986d757e9969ab3",
"grounding.go": "7152771ab1bb152946209f8e94e52bdf2facc6192b7a8ca018e37fed4557e9c0",
@@ -137,14 +154,14 @@
"ingestplan_test.go": "1ad31f4fb69d917e3f9d46f5615f38074b7ed6ec8baa01d71bd1600d317b7878",
"ingestplan_wiring_test.go": "4e5d25dace8a181418ed5857423ef1efeacdd98b7ff42b94a71c4e5fd7012e89",
"inventory.go": "d0ba0be3e2af8df3cc6dffac5b75816b9c2a0a12f6d2eff1a786d8584ca77622",
"inventory_test.go": "1821c81cf2b9e20fb82a79313686393391a6f46185dfa4a4a4c2bfb4e0e7c170",
"inventory_test.go": "16e0f83a09d61dc4cded032fd493d30bfc747c38116b871fb02b2f0bf2b0216a",
"lint.go": "63af1289978a02c9722ac8bb934a916b9e4e65cffcfec0749eb07590496960f3",
"lint_test.go": "2f57a04b9d697fba2d078eb08f49d2fec1640c7e3c61a60bc3821d2bb0798153",
"machineid.go": "d4224e684ecfbafe2efc7e0b95377f9e669ee1256a442aa72ce2cab3b219fc56",
"machineid_test.go": "5b54ac62ee1fb28b8f0a873888aeddb844af0aa74472bfb849573cfc6691a290",
"machinemark.go": "b93464c2ee89a73c0e0da38bcce5dc1ba6c1deba2fc483f28c6021c3ffdee4a0",
"main.go": "e02e7b32dd230f33e35c7ebf62d81b7599e2b86a0ef7cadc0fd34b672f42381d",
"manifest.go": "ce3733ed13c6caedad477392e14af7714a39616819477182ab8bd14eb5d8eae6",
"main.go": "fe2f69739f3ee92713f8aaca925068ecf97b33d476eee826fc1e0d5def8f0d10",
"manifest.go": "61a9403a3da46f270ce6a1610b9307fd19b794521b71a7a8f22cea36a6ea35cd",
"manifest_extractedby_test.go": "8019fe35675a1e801b7bda829ee1d647de32db03c029b1c651f71573e644c69e",
"manifest_retry_test.go": "e6b4a321ccddfeed8cdbfabe6d031d5a9c59a33b47baf0b6d21150fa8406748a",
"multiaccount_connected_test.go": "ba1817b73a5dd0dd5d300750e54d5c53bd82a75ef6e43caeddfcb2303d64ff66",
@@ -159,7 +176,7 @@
"repoguard_test.go": "ce0316a59af944c3cf391961653fe82294fd45e728d35b4b7fcd99b2b20e7b5c",
"safewrite.go": "d5dd09ffe894fb9fb94656a4bb50d246bd8c65ab21a7ad81463f43dfdacc8a57",
"safewrite_test.go": "2980f0f86f78fb265403e31d4cba6da43f4dbc16e7c4711cfccba23175578358",
"scan.go": "23993b1411af0a0308aa6614e514d8c60bcbeed6ed93087b9c5bcc2e0f4763e9",
"scan.go": "d26b74e46204697a573ed24ee427c06878349611fcaf3c35df295b826b690525",
"scan_carry_lasterror_test.go": "6fa542422fb3e00b5ec4355b5db8df7086361b2dacefd67755d75c0ddfa55575",
"scan_dedup_scale_test.go": "573b3c78bec86614bf4d2f99b7c3c3157f92c24af73cae7290428fc7f43f0362",
"scan_dedup_test.go": "580ec58596ca54aa3d29b417e7d098fd2e3455fd39bf10e7da02d453c0786df0",
@@ -169,7 +186,7 @@
"supervisor/hidewindow_windows.go": "29796dbe90ecb35e6f1451dacf956d42928c6a393271befc5c7f6863d5315fb3",
"supervisor/supervisor.go": "cb17332ce5a7190627839e624895c56b508635edbd2c29758f794d5c3bb39388",
"supervisor/supervisor_test.go": "f5582d54c55065fbc834fe96754df9c8cd6efe9f88f30208bbce84ddea08fea7",
"sync_status.go": "5f35c17a01e07146b155081714200e07eb54e5e0de191be0b2208b9e13e92daa",
"sync_status.go": "59e8e2c81ec5a1cbd46e852bd7c794c4179c76ac62561f21550a41afb9e2be2d",
"sync_status_test.go": "9314b354679f0c5090257cf6918185c33b74102bfac0acb655c3e97b81a247b3",
"t52_library_test.go": "14188d0afdad0f9aa16d19a74ea28c45c3d18fcfeabc5deb6fb0466d05778a51",
"template_install.go": "d0aa0098e431696a63ef8d3714c124591b8ba193ccd5a8359a4ea03e397ee358",
+6 -1
View File
@@ -3,5 +3,10 @@
"v0.18.30": "396f3c3b68cdd0b3",
"0.18.31": "d6b2b6083258b853",
"0.18.32": "4753db807169fe3d",
"0.18.33": "7ba0f6dd2d080925"
"0.18.33": "7ba0f6dd2d080925",
"0.18.34": "d8c7490b9d32e62e",
"0.18.35": "3bd7f3fb0124bda4",
"0.18.36": "abb2f2cc2498f071",
"0.18.37": "3bb5c94f68a26a06",
"0.18.38": "7add5da65c671548"
}
+77 -12
View File
@@ -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/exemsix)由地端打,
> 因為那需要 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. WindowsWebView2 有沒有裝、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 方法一樣叫得到。
+134 -2
View File
@@ -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"`
// PortalSessionPortalSessionExp=這台電腦對這個知識庫的 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,9 @@ type syncStatus struct {
// arcrun-rag#46:「移除並收回中」的資料夾進度(key=資料夾路徑)。
// 形狀定義在 collector/sync_status.go,這裡原樣接住不另定義一份會漂移的結構。
Retiring map[string]collector.RetiringStatus `json:"retiring,omitempty"`
// arcrun-rag#140:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
// 同上:形狀定義在 collector/sync_status.go,這裡原樣接住。
Resync map[string]collector.ResyncStatus `json:"resync,omitempty"`
}
type skippedDoc struct {
@@ -232,6 +244,10 @@ type UIFolder struct {
Retiring bool `json:"retiring,omitempty"`
RetireRemaining int `json:"retireRemaining,omitempty"` // 還剩幾筆
RetireError string `json:"retireError,omitempty"` // 失敗真因(原文,不改寫)
// arcrun-rag#140:雲端上找不到先前送過的檔案(知識庫被重裝/清空過),正在自動補送。
// 🔴 這張票的病有一半是「**沒有任何地方會說話**」——檔案在資料夾裡、AI 卻查不到,
// 而且查不出為什麼。所以補送不能靜悄悄地跑,這一行就是那句話的落點。
ResyncNote string `json:"resyncNote,omitempty"` // 一句人話(沒事=空字串,前端不畫)
}
type UIAccount struct {
Name string `json:"name"`
@@ -496,7 +512,13 @@ func (a *App) GetState() UIState {
for i, acc := range cfg.Accounts {
ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email}
for _, f := range acc.WatchFolders {
ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i})
uf := UIFolder{Path: f, AccIdx: i}
// arcrun-rag#140:雲端補送中就把那句人話帶到畫面上。
// key=資料夾路徑,與 collector 寫入 status.json 時同一把(見 direct.go 的 resync[root])。
if rs, ok := sync.Resync[f]; ok {
uf.ResyncNote = rs.Note
}
ui.Folders = append(ui.Folders, uf)
}
// 收回中的資料夾照樣列出來,只是標成「收回中」——不然按下移除之後它立刻消失,
// 使用者無從知道撤除還在跑、更看不到失敗的原因(那正是這張票的病的另一面)。
@@ -719,7 +741,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#138leo 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 +786,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 +805,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 +927,57 @@ func (a *App) OpenLogFolder() error {
// LogFolderPath 給畫面顯示用(讓使用者就算按鈕失效也知道去哪找)。
func (a *App) LogFolderPath() string { return appDir() }
// ── 資料夾結構(`inkstone/InkStoneCo#44`leo 2026-08-26)──
//
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
// 沒同步的那幾份為什麼沒上去。」——這支是桌面那半的資料入口。
//
// 🔴 **三件刻意不做的事**,每一件都是紅線:
//
// ① 不自己走一次檔案系統。樹是 collector 每輪算好落地的(BuildFolderTree ⇒
// folder-trees.json),這裡只讀。App 若自己數一次,就會出現「畫面說 A、
// 雲端說 B」——同一個資料夾兩份實作,遲早對不起來。
// ② 不去雲端拿。樹本來就是本機算的;繞一趟雲端只是把離線、額度用完、
// 退避窗口這幾種情況都變成「畫面壞掉」。
// ③ 不掛在 GetState 上。GetState 每秒被 tick 呼叫一次,而樹上限 300 個節點;
// 只有使用者真的按開那個資料夾時才需要它 ⇒ 獨立一支、按需呼叫。
//
// 回傳 nil 代表「這個資料夾還沒被回報過」(剛加入、還沒跑完第一輪),
// **不是**空樹——畫面要分得出「還沒掃到」與「掃過但裡面是空的」(arcrun-rag#106
// 那條紅線的同一個形狀:不准拿一個我們自己編的 0 去回答使用者)。
func (a *App) GetFolderTree(path string) (*collector.FolderTree, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
// 快照與 manifest 同目錄。manifest 路徑以設定檔為準(使用者可能把它指到別處),
// 讀不到設定就退回預設位置——與 statusPath() 同一個假設。
store := filepath.Join(appDir(), "folder-trees.json")
if cfg, err := loadCfg(); err == nil && strings.TrimSpace(cfg.Manifest) != "" {
store = collector.FolderTreeStorePath(cfg.Manifest)
}
s, err := collector.LoadFolderTreeStore(store)
if err != nil {
// 檔案還不存在=第一輪還沒跑完,那不是錯誤,是「還沒有」。
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if t, ok := s.Trees[path]; ok {
return &t, nil
}
// 設定裡存的路徑與 collector 寫入時用的 absRoot 可能一個帶了尾斜線、
// 一個沒有(AddFolder 直接存系統選擇器給的字串)⇒ 正規化後再找一次,
// 不要因為一個斜線就跟使用者說「還沒回報」。
if abs, err := filepath.Abs(path); err == nil {
if t, ok := s.Trees[abs]; ok {
return &t, nil
}
}
return nil, nil
}
// ── 托盤會呼叫的兩個動作(t194)──
// ShowWindow 把主視窗叫出來並帶到前景。
+495
View File
@@ -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"
)
// appHTTPTimeoutApp 動作背後是一整條工作流(可能打 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"`
}
// UIAppWorkflowApp 的一條工作流(沒有自帶畫面的 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 basecypher 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)
}
// urlPathEscapeApp 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()
}
+384
View File
@@ -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` 的 summarizeAppdetailApp、`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/loginroutes/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 /appsroutes/apps.tsX-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.tsBearer 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)
// 實例已經不認這張 sessionf.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)
}
}
}
+284
View File
@@ -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 的 chromiummacOSLinuxCI 都跑得動
//
// 跑法:`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 的 UIAppListUIAppDetail ──
//
// 🔴 這裡**不是**在假裝 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)SetAIConnect
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);
+130
View File
@@ -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
+6
View File
@@ -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)。
+657 -7
View File
@@ -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);
};
});
}
@@ -254,6 +296,217 @@ function cardSkipped(k) {
}
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
// ══════════════════════════════════════════════════════════════════════════
// 資料夾結構樹(`inkstone/InkStoneCo#44` 桌面那半,leo 2026-08-26
//
// leo 的交付定義第一段:「在 Portal 和**桌面小幫手**上,任何一個連上的資料夾
// 都攤得開它完整的巢狀子資料夾樹,每一層看得到這層有幾份、同步了幾份、
// 沒同步的那幾份為什麼沒上去。」Portal 那半已經在跑,這裡是桌面這半。
//
// 🔴 形狀規格=`inkstone/Arcrun#144`leo 2026-08-19 驗 1.4.49 之後打回 markmap):
// 「我的需求是**向右向下**,類似 terminal 的 tree**一列一列向下往後退縮**,
// 緊湊但可點擊展開,**模擬 Windows 的檔案總管的 tree**,這是一般辦公室用戶
// 都有的經驗,且可以用滑鼠輕易操作,不會佔用大面積。」
// ⇒ 一列一行、縮排、可摺疊、字級行距貼近作業系統的檔案總管。
// ⇒ **不用任何樹狀圖套件**:這個形狀就是「縮排的清單」,一個 div 一列就到位;
// 引一包 library 進來只會多一份要跟著 CIS 對齊的樣式來源。
//
// 🔴 **一個判準都不在這裡發明**(同 portal 那半的紅線):兩個數字與「為什麼沒收」
// 的分類全部由 collector 算好(`collector/foldertree.go`,判準活在 scan.go 那趟
// WalkDir)。這裡只做兩件事:把節點串成樹、把子樹的數字加起來。
// 哪天想在這裡寫「.py 算不算支援」——停手,那是第二份實作。
// ══════════════════════════════════════════════════════════════════════════
// datapath → 樹(undefined=還沒問、null=問過但小幫手還沒回報)
// open:path → 這個資料夾的樹展開了沒
// nodespath → { 節點路徑: 展開了沒 }(沒有紀錄=用預設,見 nodeIsOpen
const treeState = { data: {}, open: {}, nodes: {} };
// 資料夾路徑當不了 DOM id(含空白、斜線、中文)⇒ 折成一個穩定的短碼。
function treeBoxId(path) {
let h = 0;
for (let i = 0; i < path.length; i++) h = (h * 31 + path.charCodeAt(i)) | 0;
return 'ft' + (h >>> 0).toString(36);
}
// 子樹合計:小幫手送的每個數字都只算「這一層直接放的檔」(存兩套遲早對不起來),
// 所以要顯示「這個資料夾底下總共」就得在這裡疊一次。
// 🔴 skipped 的節點整棵不計:沒走進去就是不知道裡面有幾個檔,加 0 會讓分母說謊。
// (與 portal 的 rollupTree 同一套算法——兩邊看到的數字必須是同一個。)
function rollupTree(nodes) {
const byPath = {}, kids = {};
nodes.forEach((n) => { byPath[n.path] = n; (kids[n.parent] = kids[n.parent] || []).push(n); });
const sums = {};
function walk(n) {
const acc = { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
if (!n.skipped) {
acc.total = n.total_files; acc.synced = n.synced_files; acc.pending = n.pending_files;
acc.unsupported = n.unsupported_files; acc.excluded = n.excluded_files;
}
(kids[n.path] || []).forEach((c) => {
const x = walk(c);
acc.total += x.total; acc.synced += x.synced; acc.pending += x.pending;
acc.unsupported += x.unsupported; acc.excluded += x.excluded;
});
sums[n.path] = acc;
return acc;
}
// 根=parent 是 '-'collector/foldertree.go 刻意用它,才分得出「我是根」與「父親是根」)
(kids['-'] || []).forEach(walk);
// 孤兒節點(父親不在這份清單裡,例如樹被截斷)也要走一次,否則它整棵不會被算到
nodes.forEach((n) => { if (sums[n.path] === undefined && !byPath[n.parent]) walk(n); });
return { sums, kids, byPath };
}
// 差額必須解釋得了:總數 − 已同步 = 不支援 + 不收 + 處理中。
// 這行文案的存在理由就是 leo 那句「不上傳通常是不支援,比如程式碼、不支援的格式」
// ——畫面要自己回答,不必問人。
function gapWhy(s) {
const parts = [];
if (s.unsupported > 0) parts.push(s.unsupported + ' 份格式還讀不了');
if (s.excluded > 0) parts.push(s.excluded + ' 份不在收檔範圍(程式碼等)');
if (s.pending > 0) parts.push(s.pending + ' 份處理中');
return parts.join('・');
}
// 預設只展開根那一層——**照檔案總管的行為**:打開一個資料夾看到它底下一層,
// 要更深自己點。一次全攤開在 8000 個檔的資料夾上就是一面沒人看得完的牆。
function nodeIsOpen(path, n) {
const st = treeState.nodes[path] || {};
return st[n.path] === undefined ? n.depth === 0 : st[n.path];
}
function renderFolderTree(path) {
const box = $(treeBoxId(path));
if (!box) return;
const tree = treeState.data[path];
if (tree === undefined) { box.innerHTML = `<div class="ftmsg">讀取中…</div>`; return; }
// 🔴 分得出「還沒掃到」與「掃過、裡面是空的」——後者是 arcrun-rag#106 的正常情況
// (指定了空資料夾,它就該在畫面上存在),前者是「再等一下」。
// 兩者講同一句話,等於拿我們自己編的答案回答使用者。
if (tree === null) {
box.innerHTML = `<div class="ftmsg">同步小幫手還沒掃到這個資料夾的結構。
第一次同步跑完之後就會出現檔案多的話要等久一點按上面的立刻同步可以催它</div>`;
return;
}
const nodes = tree.nodes || [];
if (!nodes.length) {
box.innerHTML = `<div class="ftmsg">這個資料夾目前沒有任何內容(指定了就會在這裡,等你放東西進去)。</div>`;
return;
}
const r = rollupTree(nodes);
let html = '';
function emit(n) {
const s = r.sums[n.path] || { total: 0, synced: 0, pending: 0, unsupported: 0, excluded: 0 };
const kids = (r.kids[n.path] || []).slice().sort((a, b) => (a.path < b.path ? -1 : 1));
const open = kids.length ? nodeIsOpen(path, n) : false;
// 縮排 16px 一層=檔案總管的量級;一列一行、往後退縮(Arcrun#144 的形狀)。
const indent = 4 + n.depth * 16;
// 🔴 三角形用 CSS 畫(`<i>` 那個空元素),**不用 ▸▾ 字元**:
// 那兩個字在 Windows 的中文字型裡會被當成全形符號、大小與位置各機器不同,
// 而它正是 leo 2026-08-19 點名「展開字很小、滑鼠不好按」的那個東西。
// 畫出來的三角形每一台都一樣大,也才控得住點擊區。
// 可展開的那幾列是**按鈕**,不是裝飾用的 div:標上 role/tabindex/aria-expanded
// ⇒ 鍵盤按得到、輔助技術念得出「收合/展開」,機械檢查也看得見它是可操作的。
let row = `<div class="ftrow${kids.length ? ' has' : ''}${open ? ' open' : ''}" style="padding-left:${indent}px"`
+ (kids.length
? ` data-tnode="${esc(n.path)}" data-troot="${esc(path)}" role="button" tabindex="0"`
+ ` aria-expanded="${open}" title="${open ? '收合' : '展開'}${esc(n.name || '')}」"`
: '') + `>`
+ `<span class="tw">${kids.length ? '<i></i>' : ''}</span>`
+ `<span class="ic">${kids.length && open ? '📂' : '📁'}</span>`
+ `<span class="nm">${esc(n.name || '(未命名)')}</span>`;
if (n.skipped) {
// 🔴 整棵沒走進去 ⇒ 不准顯示 0/0(那會是我們自己編的數字),改講理由。
const why = n.skip_reason || '(小幫手沒說明理由)';
row += `<span class="skip">整個資料夾未收</span>`
+ `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
} else {
const full = s.total > 0 && s.synced === s.total;
row += `<span class="num${full ? ' full' : ''}">${s.synced} / ${s.total}</span>`;
const why = gapWhy(s);
if (why) row += `<span class="why" title="${esc(why)}">${esc(why)}</span>`;
}
if (kids.length && !open) row += `<span class="why">…還有 ${kids.length} 個子資料夾</span>`;
html += row + `</div>`;
if (open) kids.forEach(emit);
}
(r.kids['-'] || []).forEach(emit);
// 父親不在清單裡的孤兒(樹被截斷時會有)——照樣列出來,缺角要看得見,不要偷偷藏起來
const seen = {};
nodes.forEach((n) => { seen[n.path] = true; });
nodes.forEach((n) => { if (n.parent !== '-' && !seen[n.parent]) emit(n); });
let foot = '';
if (tree.truncated) {
foot += `<div class="ftmsg">資料夾太多,只顯示前 ${nodes.length} 個(實際有 ${tree.total_nodes} 個)。</div>`;
}
foot += `<div class="ftmsg">數字是「已同步 / 這個資料夾底下總共」。兩者不相等是正常的——
差額是還讀不了的格式不在收檔範圍的檔案程式碼等或還在處理中</div>`;
box.innerHTML = `<div class="fthead">`
+ `<button class="ghost" data-texpand="${esc(path)}">全部展開</button>`
+ `<button class="ghost" data-tcollapse="${esc(path)}">全部收合</button>`
+ (tree.reason ? `<span class="why">${esc(tree.reason)}</span>` : '')
+ `</div><div class="ftbody">${html}</div>${foot}`;
wireTree();
}
// 展開/收合某個資料夾的樹。第一次展開才去讀(同 portal 的 lazy 做法),
// 之後留在記憶體裡——它是**畫面暫存不是第二份真相源**,關掉視窗就沒了。
async function toggleFolderTree(path) {
treeState.open[path] = !treeState.open[path];
const box = $(treeBoxId(path));
const btn = document.querySelector(`[data-tree="${cssq(path)}"]`);
if (btn) btn.textContent = (treeState.open[path] ? '▾ ' : '▸ ') + '資料夾結構';
if (!box) return;
box.style.display = treeState.open[path] ? '' : 'none';
if (!treeState.open[path]) return;
if (treeState.data[path] !== undefined) { renderFolderTree(path); return; }
renderFolderTree(path); // 先畫「讀取中…」
try {
const t = await go.GetFolderTree(path);
treeState.data[path] = t || null; // 後端回 null=還沒回報過
} catch (e) {
box.innerHTML = `<div class="err">讀不到這個資料夾的結構:${esc(String(e))}</div>`;
return;
}
renderFolderTree(path);
}
// 屬性選擇器要跳脫引號——資料夾路徑什麼字元都可能有。
function cssq(s) { return String(s).replace(/["\\]/g, '\\$&'); }
function setAllTreeNodes(path, open) {
const tree = treeState.data[path];
if (!tree) return;
const st = {};
(tree.nodes || []).forEach((n) => { st[n.path] = open; });
if (!open) st[''] = true; // 全部收合時根自己留著,不然整棵消失、看起來像壞了
treeState.nodes[path] = st;
renderFolderTree(path);
}
function wireTree() {
document.querySelectorAll('[data-tnode]').forEach((el) => {
const toggle = () => {
const root = el.dataset.troot, np = el.dataset.tnode;
const st = treeState.nodes[root] || (treeState.nodes[root] = {});
const tree = treeState.data[root] || { nodes: [] };
const n = (tree.nodes || []).find((x) => x.path === np);
st[np] = !(st[np] === undefined ? (n && n.depth === 0) : st[np]);
renderFolderTree(root);
};
el.onclick = toggle;
el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
});
document.querySelectorAll('[data-texpand]').forEach((el) => {
el.onclick = () => setAllTreeNodes(el.dataset.texpand, true);
});
document.querySelectorAll('[data-tcollapse]').forEach((el) => {
el.onclick = () => setAllTreeNodes(el.dataset.tcollapse, false);
});
}
function pageLib(s, idx) {
const a = s.accounts[idx];
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
@@ -278,9 +531,12 @@ function pageLib(s, idx) {
${f.retireError ? `<div class="d folder-note">${esc(f.retireError)}</div>` : ''}` : `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">自動同步中</span>
<span class="tag">${f.resyncNote ? '補送中' : '自動同步中'}</span>
<button class="ghost" data-tree="${esc(f.path)}">${treeState.open[f.path] ? '▾' : '▸'} 資料夾結構</button>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
</div>
<div class="ftbox" id="${treeBoxId(f.path)}"${treeState.open[f.path] ? '' : ' style="display:none"'}></div>
${f.resyncNote ? `<div class="d folder-note">${esc(f.resyncNote)}</div>` : ''}`).join('')
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
<div class="d">按右上的加入資料夾選一個要自動整理的資料夾</div></div>`}`;
}
@@ -411,10 +667,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 iframemountAppUI 會在 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 +953,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 +1065,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 +1089,70 @@ async function addFolder(accIdx) {
// 那句話**在技術上是對的**,但它預設使用者要的是「只停止同步」,
// 而他要的是「我不要這份資料了」。⇒ 病不在少一句說明,在**替他決定了**。
// 現在兩個選擇都擺出來、後果各寫一行,由他挑。
// 🔴 arcrun-rag#138leo 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>
+190
View File
@@ -304,3 +304,193 @@ input[type=text], input[type=password] {
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/24hover 上浮 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:1height: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;
}
.fthead {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding-bottom: 7px; margin-bottom: 5px;
border-bottom: 1px solid rgba(var(--ink-rgb), .08);
}
.fthead button { padding: 4px 9px; font-size: 12px; }
/* 樹本體檔案總管那種緊湊但點得到的密度
overflow-x 讓很深的樹自己捲不去撐破右側版面 */
.ftbody { overflow-x: auto; padding: 1px 0; }
.ftrow {
display: flex; align-items: baseline; gap: 7px;
padding: 2px 4px 2px 0; font-size: 13px; line-height: 1.5;
white-space: nowrap; border-radius: 5px;
}
/* 有子資料夾的那幾列才可點——整列都是點擊區(三角形只有 12px,滑鼠不好瞄)。 */
.ftrow.has { cursor: pointer; user-select: none; }
.ftrow.has:hover { background: rgba(var(--amber-rgb), .08); }
.ftrow.has:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
/* 三角形** CSS 不用字元**
leo 2026-08-19 打回 markmap 的其中一條理由就是**展開字很小**
用戶要擁有觸控板否則很難閱讀 這兩個字在不同平台的中文字型裡
大小與位置都不一樣Windows 常被當全形處理畫出來的則到處一致 */
.ftrow .tw {
width: 14px; flex: none; align-self: center;
display: inline-flex; align-items: center; justify-content: center;
}
.ftrow .tw i {
display: block; width: 0; height: 0;
border-left: 5px solid rgba(var(--ink-rgb), .5);
border-top: 4px solid transparent; border-bottom: 4px solid transparent;
transition: transform .12s;
}
.ftrow.open .tw i { transform: rotate(90deg); }
.ftrow.has:hover .tw i { border-left-color: var(--amber); }
.ftrow .ic { flex: none; font-size: 12.5px; }
.ftrow .nm { font-weight: 500; }
.ftrow .num {
font-family: ui-monospace, Menlo, monospace; font-size: 12px;
color: rgba(var(--ink-rgb), .55);
}
/* 全部同步完=這一格沒有待辦,用完整墨色讓它從灰階裡浮出來(不另發明「成功色」)。 */
.ftrow .num.full { color: var(--ink); }
/* 🔴 選擇器**不要**綁在 .ftrow 底下頁首那句為什麼只收這些也用同一個 class
綁死就只有樹裡面吃得到樣式頁首那句會掉回 16px 的內文級實撞過一次
畫面上那句話比整棵樹都大聲喧賓奪主 */
/* 為什麼沒上去是次要資訊**它可以被截斷資料夾名字不行**
整棵未收的理由是一整句話不截的話一列會長到把樹推出畫面外
截掉的部分留在 title 滑鼠停著就看得到全文 */
.ftbox .why {
font-size: 12px; color: rgba(var(--ink-rgb), .45);
flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
.fthead .why { flex: 1; min-width: 0; line-height: 1.6; }
/* 整棵沒走進去:語氣是告知不是錯誤,所以不用 --err。 */
.ftrow .skip {
font-size: 11.5px; color: rgba(var(--ink-rgb), .5);
border: 1px solid rgba(var(--ink-rgb), .18); border-radius: 5px; padding: 0 5px;
}
.ftmsg {
padding: 6px 2px 2px; font-size: 12px; line-height: 1.65;
color: rgba(var(--ink-rgb), .45);
}
@@ -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()
+19
View File
@@ -0,0 +1,19 @@
//go:build !darwin && !windows
package main
// tray_other.go — 非出貨平台(Linux 等)的系統匣 no-oparcrun-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) {}
+14
View File
@@ -0,0 +1,14 @@
// Command collector 是 collector 的獨立命令列執行檔。
//
// 真正的邏輯全在 arcrun-rag/collector 套件裡(Run)。這支只是薄薄的外殼,
// 讓「單獨跑 collector」這條路(開發、除錯、伺服器端批次)繼續可用;
// 桌面 App 則直接呼叫同一個 Run——**兩者共用同一份程式碼,不是兩份**。
package main
import (
"os"
collector "arcrun-rag/collector"
)
func main() { os.Exit(collector.Run(os.Args[1:])) }
+185 -19
View File
@@ -475,6 +475,35 @@ func (c *DirectConfig) triggerURL(workflow string) string {
return fmt.Sprintf("%s/webhooks/named/%s/%s/trigger", c.CypherURL, c.Namespace, workflow)
}
// folderTreeURL 組出「回報資料夾樹」的端點(InkStoneCo#44 線 A)。
//
// 🔴 為什麼不是 named-webhook(也就是不做成一條 workflow):這件事是**登記**
// 不是知識萃取——它寫的是 portal 的登記簿(庫目錄+資料夾節點),與
// `/portal/daemon/extract`extract_workersai.go:86)、`/portal/daemon/libraries`
// 同一族、同一把 `X-Arcrun-API-Key`。做成 workflow 會為了「把結構化資料寫進登記簿」
// 在 workflow 裡塞一串 code 節點,那正是「表面走 Arcrun、實際整段寫 JS」的腹語術。
func (c *DirectConfig) folderTreeURL() string {
return c.CypherURL + "/portal/daemon/folder-tree"
}
// countsAsDocument 回答「這筆結果算不算**使用者的一份文件**」——
// 也就是該不該進 status.json 的 `extracted_ok` / `extract_failed`(=托盤上那句
// 「已整理 N 份 / ⚠ N 份失敗」)。
//
// 🔴 `folder_tree` 不算。它送的是**畫面用的資料夾結構**,不是知識、不是使用者的檔案:
// 收端只寫一把 KV,一份文件都沒有經手。
//
// 📌 這是本分支自己踩到的(InkStoneCo#44):漏掉這道閘的症狀是**空資料夾**——
// 一個檔都沒有,樹卻照樣要送(那正是 arcrun-rag#106 要的),一旦雲端連不上,
// 使用者的托盤就會顯示「⚠ 1 份失敗」而他根本沒有半份檔案。
// 「講一個假的 1」跟「安靜地少講」對使用者是同一件事(同 #104 那條紅線)。
//
// `inventory`(資料夾總覽卡)刻意**仍然算**:它是真的被寫進知識庫的一張卡,
// 使用者在雲端查得到它——與這裡的樹不是同一種東西。
func countsAsDocument(r DirectResult) bool {
return r.Type != "folder_tree"
}
// postJSON POST 一個 JSON body 到 url,回傳 HTTP 狀態碼與回應片段。非 2xx 視為錯誤。
func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
data, err := json.Marshal(body)
@@ -492,10 +521,25 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
return 0, "", err
}
defer resp.Body.Close()
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
full, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
snippet := full
if len(snippet) > 1024 {
snippet = snippet[:1024]
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
// named-webhook 觸發成功一律回 200,工作流內部失敗藏在 body 裡
//2026-08-26 實測 `InkStoneCo`26 份蓋了「已送達」章,雲端只有 4 份)。
// 判斷放在這裡而不是各呼叫端:**「忘了接」這個失敗模式不該存在**
//(同 ingestplan.go 把兩張排除表收成一張的理由)。
if msg := webhookFailure(string(full)); msg != "" {
return resp.StatusCode, string(snippet), errors.New(msg)
}
return resp.StatusCode, string(snippet), nil
}
@@ -664,6 +708,12 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// key=資料夾路徑(同一個根被多帳號看守時後寫覆蓋——策略只看資料夾,與帳號無關)。
folderPlans := map[string]FolderPlanStatus{}
// `inkstone/InkStoneCo#44`(桌面小幫手那半):每個看守資料夾這一輪的樹。
// 與 folderPlans 同一套 key 與同一套覆蓋語意。knownRoots 記「這一輪打算處理哪些根」
// ——合併時要靠它把已經不看守的根從快照裡刪掉(見 MergeFolderTreeStore ③)。
folderTrees := map[string]FolderTree{}
var knownRoots []string
// t210:跨帳號、跨資料夾累加的總量進度(見 rootProgress 註解)。
var totalProgress SyncProgress
var stuckReasons []string
@@ -672,6 +722,10 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 每輪重建、照現況重報(level-triggered),App 看到 done 才把設定裡那一筆清掉。
var retiring map[string]RetiringStatus
// `inkstone/arcrun-rag#140`:這一輪各個資料夾的「雲端補送」現況(key=資料夾路徑)。
// 同 retiring 的 level-triggered 語意:每輪照 manifest 現況重報,補完自然消失。
var resync map[string]ResyncStatus
// t215:全域「雲端最新版」只抓一次(自帶節流,見 cloud_latest.go)——
// 這是所有帳號共用的同一把尺,不是逐帳號各打一次。
latestRelease, latestOK := FetchLatestCloudRelease()
@@ -733,6 +787,19 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
r, e, p, rp := runDirectOnceRoot(accCfg, root, dryRun, qs, now)
totalProgress = totalProgress.Add(rp.Progress)
stuckReasons = append(stuckReasons, rp.StuckReasons...)
// #44:這一根的樹。**掃壞了(Nodes 空)就不要覆蓋上一輪的好資料**——
// 合併時沿用舊的(MergeFolderTreeStore ②),畫面不會突然變成「還沒回報」。
knownRoots = append(knownRoots, root)
if len(rp.Tree.Nodes) > 0 {
folderTrees[root] = rp.Tree
}
// #140:有話要說才佔畫面(零值=這個資料夾沒有補送中的事)。
if rp.Resync.Pending > 0 || rp.Resync.Repaired > 0 || rp.Resync.LastError != "" {
if resync == nil {
resync = map[string]ResyncStatus{}
}
resync[root] = rp.Resync
}
if multi {
for i := range r {
r[i].Root = root
@@ -740,7 +807,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
}
for i := range r {
r[i].Account = accHost // t104: 標明所屬帳號
if cfg.Extractor != "" {
if cfg.Extractor != "" && countsAsDocument(r[i]) {
switch r[i].Status {
case "ingested":
accSt.ExtractedOK++
@@ -828,6 +895,7 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
ExtractorError: extractorError,
AccountDetails: accountDetails,
Retiring: retiring, // arcrun-rag#46:移除並收回中的資料夾進度
Resync: resync, // arcrun-rag#140:雲端上找不到、正在自動補送的資料夾
}
// G-6.2:把「讀不了的檔」寫進狀態檔,App 首頁才有東西可以講。
// 排序=畫面每輪穩定(map 迭代順序隨機,不排的話清單會自己跳動)。
@@ -882,6 +950,9 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
// 頂層彙總(向後相容:單帳號時填頂層欄位讓舊版 tray 仍能讀)
if cfg.Extractor != "" {
for _, r := range results {
if !countsAsDocument(r) {
continue
}
switch r.Status {
case "ingested":
st.ExtractedOK++
@@ -949,6 +1020,17 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge
if serr := SaveSyncStatus(statusPath, st); serr != nil {
fmt.Fprintf(os.Stderr, "status 寫入失敗(不擋看守):%v\n", serr)
}
// `inkstone/InkStoneCo#44`:本機那份樹快照。
// 🔴 **刻意不放進 status.json**:桌面小幫手每秒讀一次 status.jsontick),
// 而一棵樹上限 300 個節點、一台機器可能看守好幾個資料夾
// ⇒ 併進去等於讓每一秒都去解析幾百 KB 只有「使用者按開樹」那一刻才要用的資料。
// 分成兩個檔,讀的人各取所需(小幫手只在展開時才讀這一份)。
treePath := FolderTreeStorePath(cfg.Manifest)
prevTrees, _ := LoadFolderTreeStore(treePath) // 讀不到=沒有上一輪,零值可用
if terr := SaveFolderTreeStore(treePath, MergeFolderTreeStore(prevTrees, folderTrees, knownRoots, now)); terr != nil {
fmt.Fprintf(os.Stderr, "資料夾結構寫入失敗(不擋看守):%v\n", terr)
}
}
return results, exit, lastPayload
@@ -1204,6 +1286,14 @@ func accountsConnected(cfg *DirectConfig) bool {
type rootProgress struct {
Progress SyncProgress // 這一根的 Total/Done/Pending/StuckUnreadable 由呼叫端補,見 progress.go
StuckReasons []string // 已放棄自動重試那些條目的 LastError 原文,交給 ClassifyFailure 分類
// Resync=這一根的「雲端補送」現況(#140)。**從 manifest 現況重算**,不是本輪計數
// ——後者在沒事做的那輪會歸零,那正是 2026-08-05 leo 實撞的「明明做完了畫面卻寫等待中」。
// 沒事=零值,呼叫端不寫進 status.json(不製造常駐噪音)。
Resync ResyncStatus
// Tree=這一根這一輪算出來的資料夾樹(`inkstone/InkStoneCo#44`,桌面小幫手那半)。
// 🔴 **就是送上雲端的那一棵**BuildFolderTree 的產物原件),不是為了畫面另算一份。
// 呼叫端把它落地成 folder-trees.json,小幫手離線也攤得開(理由見 foldertree.go 檔尾)。
Tree FolderTree
}
// qs:這個帳號本輪共用的額度冷卻狀態(跨同帳號的多個監看根,見 quota.go)。
@@ -1255,6 +1345,52 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
if err != nil {
return append(results, DirectResult{Status: "failed", Error: err.Error()}), 1, nil, rootProgress{}
}
// 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。
// 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
// 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。
// 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映
// 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。
//
// #140:定義位置從「Scan() 之後」往上搬到這裡,因為雲端對帳跑在 Scan() 之前
// 也要落盤——拔掉的章沒存進磁碟,process 被殺掉就等於沒對過帳。內容一字未動。)
saveManifest := func() {
if dryRun {
return
}
if serr := m.Save(absManifest); serr != nil {
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()})
exit = 1
}
}
// 🔴 雲端對帳(`inkstone/arcrun-rag#140`2026-08-26)——**必須在 Scan() 之前**。
// 它做的唯一一件事是把「雲端已經沒有了」的 ingested 章拔掉;拔完之後
// 下面那個 Scan() 就會因為 `orig[p].IngestedHash == ""` 自然補一發 added 事件
// scan.go 步驟 4 的既有語意「上輪偵測過但 ingest 未成功 → 重試」)
// ⇒ 補送走的是**既有的**萃取路,既有的單輪上限/失敗退避/額度冷卻全部照舊生效。
// 放在 Scan() 之後就得再等一輪才會動,且要另外發明一條送件路。見 cloud_audit.go。
auditErr := ""
if ar := auditCloudLedger(cfg, absRoot, m, dryRun, runNow); ar != nil {
auditErr = ar.Err
if ar.Voided > 0 {
results = append(results, DirectResult{
Type: "resync", Path: absRoot, Status: "noticed",
Error: fmt.Sprintf("雲端上找不到 %d 份先前送過的檔案(對帳了 %d 份,知識庫可能重裝過),已排進佇列自動補送", ar.Voided, ar.Checked),
})
}
if ar.Err != "" {
// 上游錯誤原文照顯示(leo 2026-08-06:「別人的錯誤一律要顯示給用戶看,
// 不然就會變成我的錯誤」)。對帳失敗**不設 exit**:它是保險絲不是主流程,
// 連不上雲端時檔案同步照常走。
results = append(results, DirectResult{
Type: "resync", Path: absRoot, Status: "noticed",
Error: "無法跟雲端核對哪些檔案還在(不影響同步,稍後自動再試):" + ar.Err,
})
}
saveManifest() // 拔掉的章與對帳時間當下就落盤(斷點續傳同款)
}
// 2026-08-07 task 3Scan() 會把 removed 的路徑從 m.Entries 整批拿掉(rebuild 語意,
// 見 scan.go 步驟 7)——但那只是「偵測到不見了」,不代表下架 POST 已經成功。
// 沒有這份快照的話,本輪只要有任何一個 added/modified 事件先觸發了下面的
@@ -1291,22 +1427,6 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
now := runNow.Unix()
// 2026-08-07 task 3(斷點續傳):每個事件處理完就立刻存檔,不要等整輪跑完。
// 舊行為=整個 for 迴圈跑完才 Save 一次——process 在跑到一半被殺掉(重開機、
// 換版、當機)時,**已經成功的那些也會遺失**,下次重開等於從頭來過,
// 且已經花掉的額度/請求全部白費(正是 leo 要求「不從頭來」要防的事)。
// 改成每個事件收工就存一次:kill 在任何一刻,磁碟上的 manifest 都反映
// 「這一刻之前已確定成功的事」,下一輪只會處理真正還沒做完的。
saveManifest := func() {
if dryRun {
return
}
if serr := m.Save(absManifest); serr != nil {
results = append(results, DirectResult{Status: "failed", Error: "manifest 存檔失敗(斷點續傳可能失效):" + serr.Error()})
exit = 1
}
}
// 結構先行(InkStoneCo#432026-08-15):掃描一結束(純本機、免費、秒級)就先把
// 「這個資料夾有哪些檔案/最近改了什麼」送上知識庫,**不等 LLM 萃取、不受額度影響**
// ——走 rag_ingest_card(零 LLM 的機械收口),所以刻意放在:
@@ -1323,6 +1443,34 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
}
}
// 碎形目錄索引(`inkstone/Arcrun#146`leo 2026-08-19「每個巢狀資料夾都要有 index,
// 且每一層指向子層次目錄」):接在總覽卡後面,同一個理由放在萃取迴圈之前——
// 目錄結構是本機掃一遍就有的答案,不該跟 LLM 排同一條隊、不該被額度牆擋住。
// 一卡一關聯(見 folderindex.go 檔頭)⇒ 單一請求恆定 1 條 rel,不受資料夾數量影響。
if fcRes := syncFolderCards(cfg, absRoot, m, len(payload.Events) > 0, dryRun, runNow); len(fcRes) > 0 {
results = append(results, fcRes...)
if fcRes[0].Status != "planned" {
saveManifest() // 同上:folder_card_hashes 當下就落盤,斷點續傳才接得上
}
}
// InkStoneCo#44 線 Aleo 2026-08-17):把「這個資料夾長什麼樣、每一層同步了幾份/
// 總共幾份」送上 portal。與上面的總覽卡刻意分開——那張是給檢索用的 markdown 卡,
// 這棵是給畫面用的結構化資料,消費者不同(理由全文見 foldertree.go 檔頭)。
//
// 位置與總覽卡同一段的三個理由完全通用(不等 LLM/在額度閘之外/反映現況),
// 再加第四個**只屬於它**的:**空資料夾一個事件都不會有**arcrun-rag#106 的情境本身),
// 所以它不能被任何「有事件才做」的閘擋住——分子分母都由現況算出,靜止時
// 內容雜湊自然擋住重送,不需要事件當第二道閘。
tree := BuildFolderTree(absRoot, cfg.libraryFor(absRoot), payload.DirStats, m.Entries,
payload.AllExcludedDirs, plan, runNow)
if treeRes := syncFolderTree(cfg, absRoot, m, tree, dryRun, runNow); treeRes != nil {
results = append(results, *treeRes)
if treeRes.Status != "planned" {
saveManifest() // 記住 folder_tree_hash/失敗退避
}
}
// 承上:把本輪偵測到的 removed 路徑暫時放回 m.Entries,直到迴圈裡真的處理到它、
// POST 成功才由「removed」分支明確刪除。失敗或本輪還沒輪到(單輪上限)都維持放回的狀態,
// 下一輪自然重新偵測、重新嘗試下架——不會因為別的事件先存檔而被誤永久跳過。
@@ -1517,6 +1665,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:記下失敗並排定退避,否則下輪又把它當新檔重試
@@ -1658,7 +1812,7 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
// t210manifest 走到這裡已經是本輪最終狀態(每個事件處理完就地更新),
// 原地數一次就是對的(同 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——分類判斷只住那一個接縫,
@@ -1667,6 +1821,18 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
rp.StuckReasons = append(rp.StuckReasons, e.LastError)
}
}
// #140:同一套「現況快照」邏輯——補送進度也是原地數出來的,不是本輪計數器。
if pending, repaired := ResyncSummary(m, runNow); pending > 0 || repaired > 0 || auditErr != "" {
rp.Resync = ResyncStatus{
Pending: pending,
Repaired: repaired,
LastError: auditErr, // 真因原文,不改寫(leo 2026-08-06
Note: resyncNote(pending, repaired, auditErr),
}
if m.CloudAuditAt > 0 {
rp.Resync.CheckedAt = time.Unix(m.CloudAuditAt, 0).UTC().Format(time.RFC3339)
}
}
return results, exit, payload, rp
}
+10 -1
View File
@@ -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})
+4 -2
View File
@@ -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)
}
+13 -1
View File
@@ -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)
@@ -199,7 +204,9 @@ func TestDirect_LargeBacklog_ProcessedInNewestFirstBatches(t *testing.T) {
func ingestedPaths(results []DirectResult) []string {
var out []string
for _, r := range results {
if r.Status == "ingested" && r.Type != "inventory" { // 結構先行:總覽卡另計
// 只數「檔案事件」。總覽卡(inventory)與資料夾樹(folder_treeInkStoneCo#44
// 都是每輪的結構回報,不是使用者的檔案——同 countsAsDocument 的那條線。
if r.Status == "ingested" && r.Type != "inventory" && r.Type != "folder_tree" {
out = append(out, r.Path)
}
}
@@ -230,6 +237,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)
+46
View File
@@ -51,6 +51,46 @@ import (
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
// maxWorkersAIExtractBytes=送進雲端萃取的純文字上限(位元組)。
//
// 🔴 為什麼要有這道閘(2026-08-26 實測 `InkStoneCo`,非推測):
// 這條路沒有任何長度判斷——整份原稿原封不動塞進 prompt。leo 的
// `system-dev/wiki/mistakes.md`562 KB)、`status.md`401 KB)、
// `status-archive-2026-08.md`511 KB)因此每一輪都撞同一面牆,
// 而使用者看到的是這串**沒有人讀得懂的東西**:
//
// 本地萃取失敗:雲端萃取失敗(HTTP 502):Workers AI 執行失敗:8007:
// {"error":{"message":"This model's maximum context length is 131000 tokens…
//
// ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列)
// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。
//
// 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報
// 「prompt contains at least 122,xxx tokens」,而可用輸入是
// 131,000 8,192(輸出) ≈ 122,800 ⇒ 中文原稿約 3.3 位元組/token。
// 300 KB ≈ 9 萬 token,留了三成餘裕給提示詞本身與英數混排的變異。
//
// ⚠️ 這個上限只綁**這條路**(雲端 llama-4-scout 的 131k 視窗)。
// gemma 路走 Gemini、視窗大一個數量級,不受此限——判準跟著模型走,
// 不做成全域常數,免得換模型時有人以為它是產品規格。
const maxWorkersAIExtractBytes = 300_000
// tooBigForWorkersAI 回傳「這份原稿太大,這條路讀不完」的人話理由;
// 沒超過回空字串。
//
// 🔴 訊息是產品文案不是 debug 字串:要講**多大**、**為什麼不收**、**他能做什麼**,
// 而且不准出現狀態碼、模型名或 token 這種只有工程師看得懂的詞。
func tooBigForWorkersAI(srcText, relPath string) string {
if len(srcText) <= maxWorkersAIExtractBytes {
return ""
}
// 中文一個字約 3 位元組——換算成「字數」才是使用者對得上的單位。
wan := len(srcText) / 3 / 10000
return fmt.Sprintf(
"這份檔太大了(約 %d 萬字),雲端的整理模型一次讀不完,所以這次沒有收它。"+
"把它拆成幾份小一點的檔就會自動收進來。", wan)
}
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
@@ -74,6 +114,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
}
// 🔴 明知送出去一定會失敗,就不要送(見 maxWorkersAIExtractBytes)。
// 早一步擋下=不燒額度、不占佇列,而且使用者看到的是人話不是上游錯誤碼。
if why := tooBigForWorkersAI(srcText, relPath); why != "" {
return nil, fmt.Errorf("%s", why)
}
pageName := pageNameOf(relPath)
// #134prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
+364
View File
@@ -0,0 +1,364 @@
// folderindex.go — 讓目錄索引變成**碎形**:每一層資料夾各有自己的一格,父子接得起來
// `inkstone/Arcrun#146`leo 2026-08-19)。
//
// leo 的原話(兩句,就是本檔的全部規格):
//
// 「daemon 可以**碎形**的在每個巢狀資料夾中產生 index & wiki」
// 「**每一層目錄指向子層次目錄**」
//
// 🔴 為什麼不是把 inventory.go 的上限調大:
//
// `inventory.go` 的資料模型只有**兩層**——`inventoryCardPath(library)` 讓一個監看根
// 只能有一張卡,`invTopDirOf(rel)` 只取相對路徑第一段。實測(14 檔/4 層巢狀)產出
// 1 張卡、3 條「第一層 >> part_of >> 總覽頁」,第二層以下一個節點都沒有。
// 深度是**寫死在模型裡的**,不是參數。
//
// 🔴 為什麼是「一個資料夾一張卡」而不是「一張卡帶全部關聯」:
//
// `rag_ingest_card` 對每條 rel 各發一個 POSTforeach)+upsert 前置的逐筆 DELETE。
// 把 N 個目錄的關聯塞進同一張卡 ⇒ 單一請求 N 條 rel ⇒ 撞 Workers 免費層
// **50 subrequests/請求**的天花板(`inventory.go` 的 `maxInventoryRels = 10`
// 就是為了這面牆才壓的)。拆成一卡一關聯後,**每個請求恆定 1 條 rel**,
// 天花板與資料夾數量脫鉤;變多的是請求「次數」,那個由既有的 `pace()` 節流吸收。
//
// 誠實界線(不要讓讀的人以為它做了沒做的事):
// - **空目錄不會有卡**。目錄集合是從 manifest 的檔案路徑反推的,而 `scan.go` 從來
// 沒有記錄過目錄本身(`d.IsDir()` 只用來決定要不要整棵跳過)。
// 「空資料夾也要看得見」是 `inkstone/arcrun-rag#106`,要先讓掃描記得目錄才做得到。
// - 卡片內容是**機械統計**(檔數、子資料夾、最近改了什麼),不經 LLM ——
// 與 `inventory.go` 同一個理由:這種問題本機掃一遍就有答案,不該跟萃取排同一條隊。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strings"
"time"
)
const (
// maxFolderCards=單一監看根最多產幾張資料夾卡。
//
// 🔴 這是一道**會被講出來**的上限,不是安靜截斷(principles「no silent caps」):
// 超過時 `syncFolderCards` 會在結果訊息裡指名少送了幾個、從哪一層以下開始少。
// 200 的由來:一次同步 200 個請求已經是既有 `MaxEventsPerRun`(預設同量級)的規模,
// 再多就該讓使用者知道「你的資料夾深到要分批」,而不是我們自己吞掉。
maxFolderCards = 200
// maxFolderChildrenListed=卡片上「子資料夾」段最多列幾個。純顯示上限,
// 不影響關聯(關聯永遠由子卡自己宣告,見檔頭)。
maxFolderChildrenListed = 50
// maxFolderFilesListed=卡片上「本層檔案」最多列幾個。
maxFolderFilesListed = 100
)
// FolderCard=一個資料夾在雲端的那一格。
type FolderCard struct {
// Rel=相對監看根的路徑(`""`=根本身,根不由本檔產卡,見 BuildFolderCards)。
Rel string
// Page=雲端頁名(也是三元組裡的節點名)。
Page string
// Path=合成的 source_uri 鍵(不落地本機,同 inventoryCardPath 的慣例)。
Path string
// Parent=父資料夾的頁名。這就是「每一層指向子層次目錄」的那條邊的另一端。
Parent string
// Content=卡片全文(markdown)。
Content string
}
// folderCardPageName 回傳某個相對路徑的卡片頁名。
// 根用 inventoryPageName(沿用既有那張總覽卡,不另開一張打對台);
// 子層用 `資料夾:<根名>/<相對路徑>`——帶根名是因為不同監看根可能有同名子資料夾。
func folderCardPageName(absRoot, rel string) string {
if rel == "" {
return inventoryPageName(absRoot)
}
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
if base == "" || base == "." || base == "/" {
base = "知識資料夾"
}
return "資料夾:" + base + "/" + rel
}
// folderCardPath 回傳子資料夾卡的合成路徑。
// 帶 library(每個監看根唯一)+相對路徑的 slug ⇒ 同帳號多資料夾不互撞。
// slug 為空(例如純中文路徑)時退成路徑雜湊,理由同 librarySlug 的 t89。
func folderCardPath(library, rel string) string {
slug := pathSlug(rel)
return ".arcrun-rag/wiki/cards/arcrun-資料夾-" + library + "-" + slug + ".md"
}
// pathSlug 把相對路徑轉成檔名安全的鍵。非 ASCII 一律轉底線分段(同 librarySlug 的規則),
// 全轉光就退成 sha256 前 8 hex——**路徑穩定所以鍵穩定,兩個不同路徑必然不同鍵**。
func pathSlug(rel string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range rel {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_':
b.WriteRune(r)
lastUnderscore = false
case r >= 'A' && r <= 'Z':
b.WriteRune(r + 32)
lastUnderscore = false
default:
if !lastUnderscore && b.Len() > 0 {
b.WriteRune('_')
lastUnderscore = true
}
}
}
s := strings.Trim(b.String(), "_")
sum := sha256.Sum256([]byte(rel))
short := hex.EncodeToString(sum[:])[:8]
if s == "" {
return "d_" + short
}
// 一律接上雜湊尾碼:slug 會把「A/B」與「A_B」壓成同一個字串,
// 只靠 slug 會讓兩個不同資料夾共用一張卡(靜默混層,同 t89 的形狀)。
return s + "-" + short
}
// dirsFromEntries 從 manifest 的檔案路徑反推出**所有祖先目錄**(含中間層)。
// 回傳排序過的相對路徑(不含根的 "")。
func dirsFromEntries(entries map[string]*ManifestEntry) []string {
set := map[string]bool{}
for p, e := range entries {
if e == nil {
continue
}
rel := strings.ReplaceAll(p, "\\", "/")
seg := strings.Split(rel, "/")
if len(seg) < 2 {
continue // 根層檔案,沒有祖先目錄
}
for i := 1; i < len(seg); i++ {
set[strings.Join(seg[:i], "/")] = true
}
}
out := make([]string, 0, len(set))
for d := range set {
out = append(out, d)
}
sort.Strings(out)
return out
}
// parentRel 回傳某個相對目錄的父目錄(頂層目錄的父是根,回 "")。
func parentRel(rel string) string {
if i := strings.LastIndexByte(rel, '/'); i > 0 {
return rel[:i]
}
return ""
}
// BuildFolderCards 為每一個(含有檔案的)子資料夾各做一張卡。純函式、零 IO、輸出確定。
//
// 根本身不在回傳值裡——根那張是 `inventory.go` 的總覽卡,兩者分工不重疊:
// 總覽卡回答「這個監看根整體有什麼」,資料夾卡回答「這一層有什麼、它掛在誰底下」。
func BuildFolderCards(absRoot string, entries map[string]*ManifestEntry, library string) []FolderCard {
dirs := dirsFromEntries(entries)
// 每層直屬檔案 / 每層子目錄 / 每層含子孫的總檔數
direct := map[string][]invFile{}
subtotal := map[string]int{}
children := map[string][]string{}
for p, e := range entries {
if e == nil {
continue
}
rel := strings.ReplaceAll(p, "\\", "/")
d := path.Dir(rel)
if d == "." {
d = ""
}
direct[d] = append(direct[d], invFile{path: path.Base(rel), mtime: e.Mtime})
for cur := d; ; cur = parentRel(cur) {
subtotal[cur]++
if cur == "" {
break
}
}
}
for _, d := range dirs {
p := parentRel(d)
children[p] = append(children[p], d)
}
for k := range children {
sort.Strings(children[k])
}
cards := make([]FolderCard, 0, len(dirs))
for _, rel := range dirs {
page := folderCardPageName(absRoot, rel)
var b strings.Builder
sep := " " + strings.Repeat(">", 2) + " "
b.WriteString("# " + page + "\n\n")
b.WriteString("## 一句話定義\n")
b.WriteString(fmt.Sprintf("這是「%s」底下的資料夾 `%s`:本層直接放了 %d 份文件,連同子資料夾共 %d 份。本頁由掃描自動整理,隨檔案變動即時更新,不經 AI 萃取。\n\n",
path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/")),
rel, len(direct[rel]), subtotal[rel]))
// 子資料夾:這一段就是「每一層目錄指向子層次目錄」給人看的那一面
// (給機器看的那一面是子卡自己的「## 關聯」)。
b.WriteString("## 子資料夾\n")
kids := children[rel]
if len(kids) == 0 {
b.WriteString("(沒有子資料夾)\n")
}
for i, c := range kids {
if i >= maxFolderChildrenListed {
b.WriteString(fmt.Sprintf("- (其餘 %d 個子資料夾未列出)\n", len(kids)-i))
break
}
b.WriteString(fmt.Sprintf("- %s%d 份)\n", path.Base(c), subtotal[c]))
}
b.WriteString("\n")
b.WriteString("## 本層檔案\n")
files := direct[rel]
sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path })
if len(files) == 0 {
b.WriteString("(本層沒有直接放文件,內容都在子資料夾裡)\n")
}
for i, f := range files {
if i >= maxFolderFilesListed {
b.WriteString(fmt.Sprintf("- (其餘 %d 份未列出)\n", len(files)-i))
break
}
b.WriteString("- " + f.path + "" + invDateOf(f.mtime) + "\n")
}
b.WriteString("\n")
// ── 關聯:**恰好一條**,指向父層。天花板與資料夾數量脫鉤的關鍵就在這裡。──
parent := folderCardPageName(absRoot, parentRel(rel))
b.WriteString("## 關聯\n")
b.WriteString("- " + page + sep + "part_of" + sep + parent + "\n")
cards = append(cards, FolderCard{
Rel: rel, Page: page, Path: folderCardPath(library, rel),
Parent: parent, Content: b.String(),
})
}
return cards
}
// syncFolderCards 在總覽卡之後送資料夾卡。回 nil=這輪不必送。
//
// 冪等:每張卡的內容雜湊記在 `manifest.folder_card_hashes[rel]`,沒變不送。
// 失敗不擋主流程(同 syncInventory);額度訊息換成人話。
// 上限:`maxFolderCards`,超過的**指名講出來**,不安靜吞掉。
func syncFolderCards(cfg *DirectConfig, absRoot string, m *Manifest, hasEvents, dryRun bool, now time.Time) []DirectResult {
if !hasEvents && len(m.FolderCardHashes) > 0 {
return nil // 靜止輪不重送(同 syncInventory 的③)
}
lib := cfg.libraryFor(absRoot)
cards := BuildFolderCards(absRoot, m.Entries, lib)
if len(cards) == 0 {
return nil
}
if m.FolderCardHashes == nil {
m.FolderCardHashes = map[string]string{}
}
// 先刪掉「這輪已經不存在的資料夾」的記帳,避免雜湊表無限長大。
live := map[string]bool{}
for _, c := range cards {
live[c.Rel] = true
}
for rel := range m.FolderCardHashes {
if !live[rel] {
delete(m.FolderCardHashes, rel)
}
}
// 🔴 先挑「這輪真的要送的」,**再**套上限——順序反過來就會變成永遠只重試前 200 個,
// 後面的資料夾一輩子送不上去(而且畫面上看起來一切正常)。
type pending struct {
card FolderCard
hash string
}
var todo []pending
for _, c := range cards {
sum := sha256.Sum256([]byte(c.Content))
h := "sha256:" + hex.EncodeToString(sum[:])
if m.FolderCardHashes[c.Rel] == h {
continue // 冪等:內容沒變
}
todo = append(todo, pending{card: c, hash: h})
}
if len(todo) == 0 {
return nil
}
var dropped []string
if len(todo) > maxFolderCards {
for _, t := range todo[maxFolderCards:] {
dropped = append(dropped, t.card.Rel)
}
todo = todo[:maxFolderCards]
}
var out []DirectResult
wf := cfg.CardIngestWF
if wf == "" {
wf = "rag_ingest_card"
}
mach := cfg.machineIdentity()
for _, t := range todo {
c := t.card
res := DirectResult{Type: "folder", Path: c.Path}
if dryRun {
res.Status = "planned"
out = append(out, res)
continue
}
pace()
status, _, err := cfg.postJSON(cfg.triggerURL(wf), map[string]any{
"page_name": c.Page,
"path": c.Path,
"card_content": c.Content,
"library": lib,
"machine": mach.ID,
"machine_label": mach.Label,
})
res.HTTPStatus = status
if err != nil {
res.Status = "failed"
if isQuotaExhausted(err.Error()) {
res.Error = "雲端今天的額度用完了,資料夾索引會稍後自動補送(不影響檔案同步)"
} else {
res.Error = "資料夾索引上傳失敗(不影響檔案同步):" + err.Error()
}
out = append(out, res)
// 這一張失敗就停手:後面幾百張多半會撞同一面牆,繼續撞只是把
// 「一個壞掉的雲端」變成「幾百筆一樣的錯誤」(t195 的 1387 輪教訓)。
break
}
res.Status = "ingested"
m.FolderCardHashes[c.Rel] = t.hash
out = append(out, res)
}
if len(dropped) > 0 {
out = append(out, DirectResult{
Type: "folder",
Status: "skipped",
Error: fmt.Sprintf("資料夾超過單輪上限 %d 個,這輪少送 %d 個(例:%s)——下一輪會接著送",
maxFolderCards, len(dropped), strings.Join(firstN(dropped, 3), "、")),
})
}
return out
}
// firstN 取前 n 個(不足就全給)。只給訊息用。
func firstN(ss []string, n int) []string {
if len(ss) <= n {
return ss
}
return ss[:n]
}
+10
View File
@@ -0,0 +1,10 @@
package collector
import (
"fmt"
"time"
)
func pad(i int) string { return fmt.Sprintf("%03d", i) }
func nowFixed() time.Time { return time.Unix(1787100000, 0) }
+164
View File
@@ -0,0 +1,164 @@
package collector
import (
"strings"
"testing"
)
// ent 造一份最小 manifest entries(只有 path 有意義)。
func ent(paths ...string) map[string]*ManifestEntry {
m := map[string]*ManifestEntry{}
for i, p := range paths {
m[p] = &ManifestEntry{ContentHash: "sha256:x", Mtime: int64(1787000000 + i)}
}
return m
}
// 這份就是 leo 08-19 要 demo 的形狀:4 層巢狀。
var nested = ent(
"README.md",
"01-公司制度/請假規則.md",
"01-公司制度/資訊安全/密碼原則.md",
"02-專案/教育部標案/RFP摘要.md",
"02-專案/教育部標案/會議紀錄/20260801-啟動會議.md",
"02-專案/教育部標案/會議紀錄/20260812-期中檢討.md",
"03-產品/價目表.csv",
)
func TestDirsIncludeIntermediateLevels(t *testing.T) {
got := dirsFromEntries(nested)
want := []string{
"01-公司制度", "01-公司制度/資訊安全",
"02-專案", "02-專案/教育部標案", "02-專案/教育部標案/會議紀錄",
"03-產品",
}
if len(got) != len(want) {
t.Fatalf("目錄數 %d,期望 %d%v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("第 %d 個是 %q,期望 %q", i, got[i], want[i])
}
}
}
// 🔴 本檔的核心宣稱:每個巢狀資料夾各一張卡,而且父子接得起來。
func TestFractalOneCardPerFolderAndChainReachesRoot(t *testing.T) {
cards := BuildFolderCards("/x/demo-test1", nested, "test1")
if len(cards) != 6 {
t.Fatalf("卡數 %d,期望 6(每個子資料夾一張)", len(cards))
}
byPage := map[string]FolderCard{}
for _, c := range cards {
byPage[c.Page] = c
// 每張卡的「## 關聯」恰好一條——這是「不撞 50 subrequests」的結構保證
rel := c.Content[strings.Index(c.Content, "## 關聯"):]
if n := strings.Count(rel, "\n- "); n != 1 {
t.Fatalf("%s 的關聯有 %d 條,必須恰好 1 條", c.Page, n)
}
if !strings.Contains(rel, c.Page+" >> part_of >> "+c.Parent) {
t.Fatalf("%s 的關聯不是指向父層 %s%q", c.Page, c.Parent, rel)
}
}
// 從最深的一格一路往上走,必須走得到根(=總覽卡頁名),且步數正確。
deepest := folderCardPageName("/x/demo-test1", "02-專案/教育部標案/會議紀錄")
cur, hops := byPage[deepest], 0
if cur.Page == "" {
t.Fatalf("找不到最深的那張卡 %q", deepest)
}
for {
hops++
parent, ok := byPage[cur.Parent]
if !ok {
if cur.Parent != inventoryPageName("/x/demo-test1") {
t.Fatalf("鏈斷在 %q,它的父 %q 既不是卡也不是根", cur.Page, cur.Parent)
}
break
}
cur = parent
if hops > 10 {
t.Fatal("往上走超過 10 步,疑似有環")
}
}
if hops != 3 {
t.Fatalf("從第三層走到根走了 %d 步,期望 3", hops)
}
}
// 卡片要講得出「這一層有幾份、含子層幾份」與子資料夾清單——那是給人看的「指向子層」。
func TestCardCountsAndChildrenListing(t *testing.T) {
cards := BuildFolderCards("/x/demo-test1", nested, "test1")
var top FolderCard
for _, c := range cards {
if c.Rel == "02-專案" {
top = c
}
}
if top.Page == "" {
t.Fatal("找不到 02-專案 的卡")
}
if !strings.Contains(top.Content, "本層直接放了 0 份文件,連同子資料夾共 3 份") {
t.Fatalf("份數講錯了:\n%s", top.Content)
}
if !strings.Contains(top.Content, "- 教育部標案(3 份)") {
t.Fatalf("子資料夾清單不對:\n%s", top.Content)
}
}
// t89 同款:slug 會把 "A/B" 與 "A_B" 壓成同一個字串,只靠 slug 會讓兩個資料夾共用一張卡。
func TestPathSlugDoesNotCollapseDifferentPaths(t *testing.T) {
if pathSlug("a/b") == pathSlug("a_b") {
t.Fatal("a/b 與 a_b 的鍵相同 ⇒ 兩個資料夾會共用一張卡(靜默混層)")
}
// 純中文路徑 slug 後為空,必須退成雜湊而不是空字串
z := pathSlug("專案/會議")
if z == "" || strings.HasPrefix(z, "-") {
t.Fatalf("純中文路徑的鍵不合法:%q", z)
}
if pathSlug("專案/會議") == pathSlug("專案/紀錄") {
t.Fatal("兩個不同的純中文路徑得到同一個鍵")
}
}
// 🔴 迴歸守門:上限必須套在「要送的」上,不是套在「全部」上。
// 反過來寫的話,第 201 個之後的資料夾永遠送不上去,而畫面上一切正常。
func TestCapAppliesToPendingNotAll(t *testing.T) {
paths := make([]string, 0, maxFolderCards+20)
for i := 0; i < maxFolderCards+20; i++ {
paths = append(paths, "d"+pad(i)+"/f.md")
}
entries := ent(paths...)
cards := BuildFolderCards("/x/root", entries, "lib")
if len(cards) != maxFolderCards+20 {
t.Fatalf("卡數 %d,期望 %d", len(cards), maxFolderCards+20)
}
m := &Manifest{Entries: entries, FolderCardHashes: map[string]string{}}
// 假裝前 maxFolderCards 個都已經送成功(雜湊相同)
for _, c := range cards[:maxFolderCards] {
m.FolderCardHashes[c.Rel] = hashOf(c.Content)
}
cfg := &DirectConfig{}
res := syncFolderCards(cfg, "/x/root", m, true, true /*dryRun*/, nowFixed())
if len(res) != 20 {
t.Fatalf("這輪排到 %d 張,期望剩下的 20 張——上限若套在全部上,這裡會是 0", len(res))
}
}
func TestIdempotentWhenNothingChanged(t *testing.T) {
m := &Manifest{Entries: nested, FolderCardHashes: map[string]string{}}
cfg := &DirectConfig{}
first := syncFolderCards(cfg, "/x/demo-test1", m, true, true, nowFixed())
if len(first) != 6 {
t.Fatalf("第一輪 %d 張,期望 6", len(first))
}
// dryRun 不寫雜湊,改用真的算一次填進去模擬送成功
for _, c := range BuildFolderCards("/x/demo-test1", nested, "") {
m.FolderCardHashes[c.Rel] = hashOf(c.Content)
}
if second := syncFolderCards(cfg, "/x/demo-test1", m, true, true, nowFixed()); second != nil {
t.Fatalf("內容沒變卻還要送 %d 張", len(second))
}
}
+447
View File
@@ -0,0 +1,447 @@
// foldertree.go — 把「這個看守資料夾長什麼樣」做成**結構化的樹**送上雲端
// InkStoneCo#44 線 Aleo 2026-08-17 規格)。
//
// 🔴 leo 的原話就是這支檔的規格:
//
// 「昨天我看到的是掃到了指定資料夾,但 **tree 在 portal 上應該顯示該資料夾的 tree**,
// 並顯示該資料夾**每個子資料夾的「同步文件數/總文件數」**,
// 不上傳通常是不支援,比如程式碼、不支援的格式。」
//
// ⇒ 這個畫面同時要回答「哪些沒上傳、為什麼」。使用者不必問人、也不必看 log。
//
// ── 為什麼不是重用 inventory.go 的總覽卡 ─────────────────────────────────────
// `inventory.go` 已經在送一張「資料夾總覽」的**知識卡**(markdown),但它服務的是
// **檢索**(讓 rag-chat 查得到「這個資料夾有什麼」),而且:
// - 它只數 manifest entries **只數收得下的檔**,算不出「總文件數」的分母;
// - 它只到第一層目錄,沒有樹;
// - 它是一段 markdown ⇒ portal 沒辦法拿它畫出可摺疊、每節點兩個數字的樹。
//
// ⇒ 兩者消費者不同(一個給 LLM 檢索、一個給 portal 畫面),資料形狀也不同。
// **但分子分母只有一個來源**:本檔的分母(總數/不支援/被排除)由 `Scan()` 走訪時
// 順手數出來(同一趟走訪、同一套判準,見 scan.go 的 dirStat),分子(已同步)由
// manifest 現況導出 ⇒ 不會出現「畫面說 A、檔案說 B」那種兩份實作漂移的病。
//
// ── 為什麼「空資料夾」是這支檔的一等公民 ────────────────────────────────────
// `arcrun-rag#106`leo 2026-08-15):
//
// 「如果指定同步的是**空資料夾**,雲端不出現,**這個不行**,因為
// 我指定資料夾雲端和地端是 navigate 的功能,
// **不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?**」
//
// 今天雲端的庫是「有卡才有庫」的副作用 ⇒ 空資料夾指定了什麼都不會出現。
// 本檔**每一輪都送**(內容沒變才跳過),而且**沒有檔案時照送**——所以指定的當下
// 那個資料夾就在雲端存在了,內容是 0。收端負責順手把庫登記起來(冪等)。
//
// 紅線對齊:本檔不寫任何本機檔案、不動使用者原稿;資料一律走既有的 portal daemon
// HTTP 端點(與 `/portal/daemon/extract`、`/portal/daemon/libraries` 同一族,
// 同一把 `X-Arcrun-API-Key`),不新開第二套上行機制。
package collector
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
const (
// MaxFolderTreeNodes:一棵樹最多送幾個節點。
//
// 為什麼要有上限:leo 的 `InkStoneCo` 一個資料夾就掃到 8,339 份檔,目錄數同量級。
// 一次把幾千個節點推上去,既撞 Workers 的 subrequest 天花板,也會把 portal 畫成
// 一面沒人看得完的牆。
//
// 🔴 **超過不准安靜地截掉**——`FolderTree.Truncated` 會被送上去、由畫面講出來
//(同 #104「排除規則要看得見」那條紅線:安靜地少講與講一個 0,對使用者是同一件事)。
MaxFolderTreeNodes = 300
// folderTreeRetryDelay:整棵樹送失敗後,同一份內容多久才准再試。
// 理由同 inventory.go:積壓時每輪都有事件,沒有退避就是每 5 秒撞一次(t195 教訓)。
folderTreeRetryDelay = 10 * time.Minute
// folderTreeMinInterval**成功送出**之後,同一個資料夾至少隔多久才准再送。
//
// 🔴 為什麼需要這道閘(而總覽卡不需要):總覽卡多了一條「本輪無事件就不送」,
// 而這棵樹刻意沒有那一條(空資料夾從頭到尾沒有事件,見下面 syncFolderTree 的說明)。
// 少了那道閘,初次同步時的行為是:每收完一個檔「已同步數」就變一次
// ⇒ 內容雜湊每輪都不同 ⇒ **每 5 秒送一整棵樹**。
// 收端一次回報=一次 KV 寫入,而 Workers KV 免費層是 1000 writes/day
// ⇒ 沒有這道閘,一個資料夾的初次同步就能在一小時內把當天額度燒光,
// 而額度燒光的症狀是「畫面停在某個數字不動」——看起來像壞掉,不像額度。
//
// 2 分鐘是「人盯著畫面等進度」與「一天的額度」之間的折衷:
// 最壞 30 次/小時 × 24 = 720 次/天/資料夾,靜止時是 0(雜湊閘擋住)。
// **首次送出不受此限**m.FolderTreeHash == "")——指定資料夾的當下就要看得見它,
// 那正是 arcrun-rag#106 的重點。
folderTreeMinInterval = 2 * time.Minute
)
// FolderNode=樹上的一個節點(=地端的一個資料夾)。
//
// 🔴 數字全部是**這一層直接放的檔案**(不含子資料夾)。子樹的合計由畫面自己疊——
// 存兩套(直接數+累計數)就是同一件事兩份實作,遲早對不起來。
type FolderNode struct {
// Path=相對監看根的路徑,`""` 代表根自己。一律 `/` 分隔(跨平台一致)。
Path string `json:"path"`
// Name=這一層的名字(根用資料夾 basename,人看得懂「這是哪個資料夾」)。
Name string `json:"name"`
// Parent=上一層的 Path;根自己是 `"-"`(空字串已經被根佔走,要分得出「我是根」)。
Parent string `json:"parent"`
Depth int `json:"depth"`
// TotalFiles=這一層看得到的檔案總數(**分母**)——含不支援的、含被策略排除的。
// 這就是使用者在 Finder 裡數得出來的那個數字(隱藏檔除外,他也看不到)。
TotalFiles int `json:"total_files"`
// SyncedFiles=已經送進雲端知識庫、而且送上去之後內容沒再變過的(**分子**)。
SyncedFiles int `json:"synced_files"`
// PendingFiles=認得、還沒送完(排隊中/退避中/已放棄自動重試)。
PendingFiles int `json:"pending_files"`
// UnsupportedFiles=副檔名我們還讀不了的(leo 講的「不支援的格式」)。
UnsupportedFiles int `json:"unsupported_files"`
// ExcludedFiles=收檔策略決定不收的(leo 講的「程式碼」多半落在這裡,見 ingestplan.go)。
ExcludedFiles int `json:"excluded_files"`
// Skipped=這整棵子樹被剪掉了(沒有走進去),所以上面的數字**全部是 0 而且不是事實**。
// 🔴 畫面看到 Skipped 必須改講 SkipReason,不准顯示 `0/0`——那會是我們自己編的數字。
Skipped bool `json:"skipped,omitempty"`
// SkipReason=一句話講給使用者聽的「為什麼整個沒收」(原文來自 IngestPlan.SkipsDirWhy)。
SkipReason string `json:"skip_reason,omitempty"`
}
// FolderTree=一個監看根的整棵樹,加上「為什麼只收這些」的那句話。
type FolderTree struct {
Library string `json:"library"` // 歸庫鍵(cfg.libraryFor(absRoot),每根唯一)
DisplayName string `json:"display_name"` // 資料夾 basename,給人看的
Root string `json:"root"` // 地端絕對路徑(navigator:回得去地端)
Mode string `json:"mode"` // 收檔策略(allcurated-wikidocs-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)。
// entriesScan() 之後的 manifest 現況(分子的唯一來源)。
// excludedDirs=整棵被剪掉的目錄與理由(Scan() 記的那份,**未裁切的全量**)。
func BuildFolderTree(absRoot, library string, dirs map[string]*dirStat, entries map[string]*ManifestEntry, excludedDirs []ExcludedDir, plan IngestPlan, now time.Time) FolderTree {
nodes := map[string]*FolderNode{}
// 遞迴閉包(補祖先鏈要呼叫自己)⇒ 必須先宣告再賦值。
var ensure func(rel string) *FolderNode
ensure = func(rel string) *FolderNode {
if n, ok := nodes[rel]; ok {
return n
}
n := &FolderNode{Path: rel, Name: folderNodeName(absRoot, rel), Parent: folderNodeParent(rel), Depth: folderNodeDepth(rel)}
nodes[rel] = n
// 中間層可能沒被 dirStat 記到(例如只有被剪掉的子目錄),補齊祖先鏈——
// 少一層,畫面就接不成一棵樹(會變成一堆浮著的節點)。
if rel != "" {
ensure(n.Parent)
}
return n
}
ensure("") // 根一定存在,就算資料夾是空的(arcrun-rag#106 的整個重點)
for rel, st := range dirs {
if st == nil {
continue
}
n := ensure(rel)
n.TotalFiles = st.total
n.UnsupportedFiles = st.unsupported
n.ExcludedFiles = st.excluded
}
// 分子:manifest 現況。已送達且送上去之後沒再改過=已同步,其餘=還在路上。
// 判準與 progress.go 的 Done 完全一致(同一句 `IngestedHash == ContentHash`),
// 不另立第二把尺。
for rel, e := range entries {
if e == nil {
continue
}
n := ensure(folderOfRel(rel))
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
n.SyncedFiles++
} else {
n.PendingFiles++
}
}
// 整棵被剪掉的目錄:列出來、講理由,數字留 0 但標 Skipped
//(沒走進去就是不知道裡面有幾個檔——寫 0 當事實就是我們在編數字)。
for _, ed := range excludedDirs {
n := ensure(ed.Path)
n.Skipped = true
n.SkipReason = ed.Reason
}
out := make([]FolderNode, 0, len(nodes))
for _, n := range nodes {
out = append(out, *n)
}
// 排序=畫面每輪穩定(map 迭代順序隨機)。根永遠第一,其餘按路徑字母序。
sort.Slice(out, func(i, j int) bool {
if (out[i].Path == "") != (out[j].Path == "") {
return out[i].Path == ""
}
return out[i].Path < out[j].Path
})
total := len(out)
truncated := false
if total > MaxFolderTreeNodes {
out = out[:MaxFolderTreeNodes]
truncated = true
}
return FolderTree{
Library: library,
DisplayName: folderNodeName(absRoot, ""),
Root: absRoot,
Mode: string(plan.Mode),
Reason: plan.Reason,
Nodes: out,
Truncated: truncated,
TotalNodes: total,
GeneratedAt: now.Unix(),
}
}
// folderOfRel 回傳某個檔案相對路徑所屬的目錄(根層檔案回 `""`)。
func folderOfRel(rel string) string {
rel = strings.ReplaceAll(rel, "\\", "/")
if i := strings.LastIndexByte(rel, '/'); i > 0 {
return rel[:i]
}
return ""
}
func folderNodeParent(rel string) string {
if rel == "" {
return "-" // 根:刻意不是空字串,不然分不出「我是根」與「我的父親是根」
}
return folderOfRel(rel)
}
func folderNodeDepth(rel string) int {
if rel == "" {
return 0
}
return strings.Count(rel, "/") + 1
}
// folderNodeName 回傳節點顯示名。根用監看資料夾自己的 basename——
// 使用者認得的是「我那個資料夾叫什麼」,不是一個空字串。
func folderNodeName(absRoot, rel string) string {
if rel != "" {
return path.Base(rel)
}
base := path.Base(strings.ReplaceAll(strings.TrimRight(absRoot, "/\\"), "\\", "/"))
if base == "" || base == "." || base == "/" {
return "知識資料夾"
}
return base
}
// Hash 回傳整棵樹的內容雜湊(不含 GeneratedAt——時間每輪都變,含進去就等於沒有冪等)。
func (t FolderTree) Hash() string {
c := t
c.GeneratedAt = 0
data, err := json.Marshal(c)
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// syncFolderTree 每輪掃描後把樹送上雲端。回 nil=這輪不必送。
//
// 冪等/防撞四層(前三層同 inventory.go 的慣例,刻意不另立一套):
//
// ①整棵樹的內容雜湊記在 manifest.folder_tree_hash,沒變不送;
// ②同一份內容剛失敗過 → 退避窗口內不重撞;
// ③**成功送過之後有最小間隔**folderTreeMinInterval,理由見該常數);
// ④收端整棵覆寫(同一個 library 一把 KV),重送不堆副本、也不會留下半棵樹。
//
// 🔴 與 syncInventory 的差別:**沒有「本輪無事件就不送」那一條**。
// 空資料夾從頭到尾都沒有事件(arcrun-rag#106 的情境本身),漏掉這一送,
// 「指定了資料夾雲端就看得到」這件事永遠不會發生。
// ——但正因為拿掉了那道閘,才必須補上第③層,否則初次同步會變成每 5 秒送一次。
//
// 🔴 **整棵樹一次送**(本檔早先的版本切成每批 20 個節點,已拿掉):
// 分批的唯一理由是「收端每個節點一次 KBDB 寫入=一個 subrequest」,而收端
// `cypher-executor` 的 `/portal/daemon/folder-tree`)把樹存成一把 KV、整棵覆寫
// ⇒ 一次回報只有一次寫入。分批在那個設計下只剩壞處:中途失敗留下半棵樹,
// 而且要另一套 prune 邏輯去清「這次沒報上來的節點」。
//
// 失敗不擋同步主流程(不設 exit):樹是畫面層,它壞了檔案同步照常走;
// 但誠實回報 failed,不假綠。
func syncFolderTree(cfg *DirectConfig, absRoot string, m *Manifest, tree FolderTree, dryRun bool, now time.Time) *DirectResult {
h := tree.Hash()
if h != "" && h == m.FolderTreeHash {
return nil // ①內容沒變
}
if h != "" && h == m.FolderTreeFailHash && now.Unix() < m.FolderTreeNextRetry {
return nil // ②同一份內容剛失敗過,退避窗口內不重撞
}
// ③首次送出不受最小間隔限制(指定資料夾的當下就要看得見它);之後才節流。
if m.FolderTreeHash != "" && now.Unix() < m.FolderTreeNextSend {
return nil
}
res := &DirectResult{Type: "folder_tree", Path: absRoot}
if dryRun {
res.Status = "planned"
return res
}
pace() // 觸發雲端前一律節流(2026-08-07 pacing 慣例)
// Nodes 至少有根一個(BuildFolderTree 的 ensure("")),空資料夾照送——
// 收端據此把庫登記起來,這正是 arcrun-rag#106 的解。
body := map[string]any{
"library": tree.Library,
"display_name": tree.DisplayName,
"root": tree.Root,
"mode": tree.Mode,
"reason": tree.Reason,
"truncated": tree.Truncated,
"total_nodes": tree.TotalNodes,
"generated_at": tree.GeneratedAt,
"sync_token": h,
"nodes": tree.Nodes,
}
status, _, err := cfg.postJSON(cfg.folderTreeURL(), body)
res.HTTPStatus = status
if err != nil {
res.Status = "failed"
// 🔴 **這句話會出現在使用者的畫面上**(status.json → 托盤),所以不准裸露
// 錯誤碼/HTTP 狀態/上游 JSON 原文(同 direct_quota_test.go 那份禁字表:
// 「4006」「502」「HTTP」「neurons」)。技術細節不必寫進這句——
// `res.HTTPStatus` 已經把狀態碼結構化地留著了,要查的人查得到。
//
// 📌 這是本分支自己踩到的:原本寫成 `… + err.Error()`,於是額度耗盡那天
// 使用者看到的會是 `HTTP 502{"error":"4006: you have used up your daily
// free allocation of 10,000 neurons"}`——技術上正確,人話上是零。
// 分類走 inventory.go 早就在用的 `isQuotaExhausted`InkStoneCo#43 立的那支),
// 不另造第二套判斷。
if isQuotaExhausted(err.Error()) {
res.Error = "雲端今天的額度用完了,資料夾結構會稍後自動補送(不影響檔案同步)"
} else {
res.Error = "資料夾結構這次沒送上去,稍後會自動再試(不影響檔案同步)"
}
m.FolderTreeFailHash = h
m.FolderTreeNextRetry = now.Add(folderTreeRetryDelay).Unix()
return res
}
res.Status = "ingested"
m.FolderTreeHash = h
m.FolderTreeFailHash = ""
m.FolderTreeNextRetry = 0
m.FolderTreeNextSend = now.Add(folderTreeMinInterval).Unix()
return res
}
// ── 本機那一份:桌面小幫手要攤開的,是同一棵樹 ─────────────────────────────
//
// 🔴 為什麼要在本機也落地一份(`inkstone/InkStoneCo#44`leo 2026-08-26):
//
// leo 的交付定義第一段是「**在 Portal 和桌面小幫手上**,任何一個連上的資料夾
// 都攤得開它完整的巢狀子資料夾樹」。上面的 syncFolderTree 只完成了前半——
// 樹送上雲端、由 portal 讀回來畫。**桌面小幫手不能走那條路**,三個理由:
//
// ① 它要在**離線**、雲端額度用完、或 syncFolderTree 正在退避窗口裡的時候
// 照樣攤得開——那些正是使用者最想知道「到底同步到哪了」的時刻;
// ② 樹是**本機算出來的**(分母來自這台電腦的 WalkDir),繞一趟雲端再拿回來,
// 等於讓本機畫面依賴一條它根本不需要的網路;
// ③ syncFolderTree 有內容雜湊閘與最小間隔,**它回 nil 的輪次遠多於送出的輪次**
// ——本機畫面不該跟著那道為了省 KV 額度而設的閘一起沉默。
//
// 🔴 **不另算第二套**(本票紅線):這裡存下來的就是 BuildFolderTree 交出、
// 也正要送上雲端的那個 `FolderTree`,一個欄位都不重組、一個數字都不重算。
// 桌面與 portal 看到的若有一天不一樣,那只可能是「哪一邊比較舊」,
// 不可能是「兩邊各自算」。
// FolderTreeStore=本機的樹快照(key=監看根的絕對路徑)。
//
// 為什麼是「一個檔裝全部」而不是每個根一個檔:桌面小幫手拿得到的是
// config 裡那串資料夾路徑,用路徑當 key 直接查得到;每根一檔就得讓 App 去
// 複製 manifestPathFor 那條 host+路徑的雜湊公式——**那才是第二份實作**。
type FolderTreeStore struct {
UpdatedAt string `json:"updated_at"`
Trees map[string]FolderTree `json:"trees"`
}
// FolderTreeStorePath 回傳本機樹快照的路徑:與 manifeststatus.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
}
+207
View File
@@ -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 了——那支每秒讀一次,不該扛這個")
}
}
+298
View File
@@ -0,0 +1,298 @@
// foldertree_test.go — 資料夾樹(InkStoneCo#44leo 2026-08-17
//
// leo 的驗收情境就是這裡測的東西:
//
// 「指定一個**有子資料夾、且混著支援與不支援檔案**的資料夾 → portal 出現那棵樹,
// 每個節點兩個數字對得上實際檔案數」
// 「指定一個**空資料夾** → 它也要出現」
//
// 🔴 本檔測的是「分子分母對不對得上實際檔案」——那正是這條規格的重點:
// **兩個數字不相等是正常的,但差額必須解釋得了**。所以每個案例都驗不變式
//
// total == synced + pending + unsupported + excluded
//
// 而不是只驗「有送出去」。
package collector
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// answeredFolderTreePost 讓「假 cypher」認得資料夾樹這個端點:是它就自己回 200 並回報 true,
// 呼叫端 `return` 即可,不要把它算進卡片的統計裡。
//
// 🔴 這支存在的理由是一個實撞(2026-08-17,本分支自己踩的):本套件的假 cypher 幾乎都是
// **萬用 handler**——「收到什麼都當成一次卡片 POST」,然後 `m["page_name"].(string)`。
// #44 之後多了第三個端點(`/portal/daemon/folder-tree`),它的 body **沒有 page_name**
// ⇒ 那些 handler 拿 nil 做型別斷言而 panic。而 panic 發生在 handler 的 goroutine 裡
// ⇒ 測試不是紅,是**掛住到 8 分鐘 timeout**(比紅更難查:看起來像「測試很慢」)。
//
// ⇒ 以後再加第四個 daemon 端點時,凡是「數 POST 幾次/記 page_name」的 stub
// 都要先問過這一句,不要再讓萬用 handler 去猜。
func answeredFolderTreePost(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
return false
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return true
}
// nodeOf 從一棵樹裡撈出某個路徑的節點(找不到就讓測試自己 Fatal,不回 nil 讓後面 panic)。
func treeNodeOf(t *testing.T, tree FolderTree, path string) FolderNode {
t.Helper()
for _, n := range tree.Nodes {
if n.Path == path {
return n
}
}
t.Fatalf("樹裡沒有節點 %q,實際有:%v", path, nodePaths(tree))
return FolderNode{}
}
func nodePaths(tree FolderTree) []string {
out := make([]string, 0, len(tree.Nodes))
for _, n := range tree.Nodes {
out = append(out, n.Path)
}
return out
}
// 掃一個真實的暫存資料夾,把 Scan() 數出來的分母與 manifest 現況合成樹。
// 刻意走真的 Scan()——分母的判準活在那趟 WalkDir 裡,繞過它就等於測了另一套實作。
func buildTreeFromDisk(t *testing.T, root string) FolderTree {
t.Helper()
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
plan := PlanIngest(root)
payload, err := Scan(root, m, ScanOptions{Plan: plan})
if err != nil {
t.Fatalf("Scan 失敗:%v", err)
}
return BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1786900000, 0))
}
// ── leo 的驗收情境①:有子資料夾、混著支援與不支援的檔案 ────────────────────────
func TestFolderTree混合資料夾的兩個數字對得上(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
mustWrite(t, filepath.Join(root, "docs", "流程.md"), "# 流程\n內容")
mustWrite(t, filepath.Join(root, "docs", "簡報.pptx"), "binary-ish")
mustWrite(t, filepath.Join(root, "docs", "main.go"), "package main")
mustWrite(t, filepath.Join(root, "img", "logo.png"), "PNG")
tree := buildTreeFromDisk(t, root)
// 樹要長得出來(根+docs+img),而且根一定在第一個(畫面靠這個順序)
if len(tree.Nodes) < 3 {
t.Fatalf("節點太少:%v", nodePaths(tree))
}
if tree.Nodes[0].Path != "" {
t.Fatalf("根不在第一個:%v", nodePaths(tree))
}
if tree.DisplayName == "" {
t.Error("根沒有顯示名——使用者認得的是資料夾名字,不是空字串")
}
// 🔴 不變式:每一層的分母必須解釋得完(差額=不支援+不收+還在路上)
for _, n := range tree.Nodes {
if n.Skipped {
continue // 沒走進去 ⇒ 數字全 0 且不是事實,見下一支測試
}
sum := n.SyncedFiles + n.PendingFiles + n.UnsupportedFiles + n.ExcludedFiles
if n.TotalFiles != sum {
t.Errorf("節點 %q 的差額解釋不了:total=%d 但 synced+pending+unsupported+excluded=%d%+v",
n.Path, n.TotalFiles, sum, n)
}
}
// docs 這一層:Finder 裡看得到 3 個檔 ⇒ 分母就是 3(不是「我們收得下的那 1 個」)。
// 這是整條規格的核心:分母是使用者數得出來的那個數字。
docs := treeNodeOf(t, tree, "docs")
if docs.TotalFiles != 3 {
t.Errorf("docs 分母=%d,應為 3.md/.pptx/.go 使用者都看得到):%+v", docs.TotalFiles, docs)
}
// 且「沒上傳的那些」要分得出是哪一類——leo:「不上傳通常是不支援,比如程式碼、不支援的格式」
if docs.UnsupportedFiles+docs.ExcludedFiles == 0 {
t.Errorf("docs 有讀不了/不收的檔,卻一個都沒分類到:%+v", docs)
}
// 每個節點都要有名字與父親,畫面才串得成一棵樹(少一環就變一堆浮著的節點)
for _, n := range tree.Nodes {
if n.Path != "" && n.Parent == "-" {
t.Errorf("非根節點 %q 的 parent 是 '-'(只有根才准)", n.Path)
}
if n.Name == "" {
t.Errorf("節點 %q 沒有名字", n.Path)
}
}
}
// ── leo 的驗收情境②:空資料夾也要出現(arcrun-rag#106)────────────────────────
func TestFolderTree空資料夾照樣有根節點(t *testing.T) {
root := t.TempDir()
tree := buildTreeFromDisk(t, root)
if len(tree.Nodes) != 1 || tree.Nodes[0].Path != "" {
t.Fatalf("空資料夾應恰好有一個根節點:%v", nodePaths(tree))
}
if tree.Nodes[0].TotalFiles != 0 {
t.Errorf("空資料夾的分母應為 0%+v", tree.Nodes[0])
}
// leo:「不能因為地端資料夾內沒東西就當作不存在,如果那是他打算放東西的資料夾呢?」
// ⇒ 樹要送得出去(有 library、有 root),收端才登記得起這個庫。
if tree.Library == "" || tree.Root == "" {
t.Errorf("空資料夾的樹缺 library/root,收端登記不了:%+v", tree)
}
}
// 走進得去但一個檔都沒有的子資料夾,也要有自己的節點
//(leo 的規格是「不管那層有沒有文件,整棵樹都要採」)。
func TestFolderTree空的子資料夾也有節點(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.md"), "# A")
if err := os.MkdirAll(filepath.Join(root, "打算放東西的地方"), 0o755); err != nil {
t.Fatal(err)
}
tree := buildTreeFromDisk(t, root)
n := treeNodeOf(t, tree, "打算放東西的地方")
if n.TotalFiles != 0 || n.Skipped {
t.Errorf("空的子資料夾應該是「有節點、數字 0、沒被剪掉」:%+v", n)
}
}
// ── 冪等與雜湊 ───────────────────────────────────────────────────────────────
func TestFolderTreeHash不含時間(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.md"), "# A")
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
plan := PlanIngest(root)
payload, err := Scan(root, m, ScanOptions{Plan: plan})
if err != nil {
t.Fatal(err)
}
t1 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(1000, 0))
t2 := BuildFolderTree(root, "kb", payload.DirStats, m.Entries, payload.AllExcludedDirs, plan, time.Unix(9999, 0))
if t1.Hash() != t2.Hash() {
t.Error("同一份內容在不同時間算出不同雜湊 ⇒ 冪等閘失效,會每輪重送")
}
if t1.Hash() == "" {
t.Error("雜湊算不出來")
}
}
// ── 上傳行為:整棵一次送、成功後節流、失敗退避 ──────────────────────────────
func TestSyncFolderTree整棵一次送(t *testing.T) {
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
t.Errorf("打錯端點:%s", r.URL.Path)
}
if r.Header.Get("X-Arcrun-API-Key") == "" {
t.Error("沒帶 X-Arcrun-API-Key(收端會 401")
}
raw, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(raw, &m)
bodies = append(bodies, m)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
for i := 0; i < 50; i++ { // 遠超過舊版 20 個節點一批的分批門檻
mustWrite(t, filepath.Join(root, "d"+string(rune('a'+i%26))+string(rune('a'+i/26)), "x.md"), "# X")
}
tree := buildTreeFromDisk(t, root)
if len(tree.Nodes) <= 20 {
t.Fatalf("前置沒建出足夠節點:%d", len(tree.Nodes))
}
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
now := time.Unix(1786900000, 0)
res := syncFolderTree(cfg, root, m, tree, false, now)
if res == nil || res.Status != "ingested" {
t.Fatalf("應送達:%+v", res)
}
// 🔴 一次請求送完整棵樹:分批只在「收端每節點一次寫入」的設計下才有意義,
// 而收端改成整棵覆寫後,分批只會留下半棵樹的中間狀態。
if len(bodies) != 1 {
t.Fatalf("應恰好一次請求,實際 %d 次", len(bodies))
}
sent, _ := bodies[0]["nodes"].([]any)
if len(sent) != len(tree.Nodes) {
t.Errorf("送出的節點數 %d ≠ 樹的節點數 %d(有被切掉)", len(sent), len(tree.Nodes))
}
if bodies[0]["sync_token"] == "" || bodies[0]["library"] != "kb" {
t.Errorf("body 缺 sync_token/library%+v", bodies[0])
}
// 內容沒變 → 不重送
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Hour)); again != nil {
t.Errorf("內容沒變不該重送:%+v", again)
}
// 內容變了、但還在最小間隔內 → 也不送(沒有這道閘,初次同步會每 5 秒送一次)
changed := tree
changed.Nodes = append([]FolderNode{{Path: "新", Name: "新", Parent: "", Depth: 1}}, tree.Nodes...)
if soon := syncFolderTree(cfg, root, m, changed, false, now.Add(30*time.Second)); soon != nil {
t.Errorf("最小間隔內不該送:%+v", soon)
}
// 過了最小間隔 → 送
if later := syncFolderTree(cfg, root, m, changed, false, now.Add(folderTreeMinInterval+time.Second)); later == nil {
t.Error("過了最小間隔應該要送")
}
if len(bodies) != 2 {
t.Errorf("應總共兩次請求,實際 %d", len(bodies))
}
}
func TestSyncFolderTree首次不受最小間隔限制(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
tree := buildTreeFromDisk(t, root) // 空資料夾
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
// FolderTreeNextSend 是零值(從沒送過)⇒ 就算「現在」小於它也不能被擋
// ——指定資料夾的當下就要看得見它,那正是 #106 的重點。
if res := syncFolderTree(&DirectConfig{CypherURL: srv.URL, APIKey: "demo"}, root, m, tree, false, time.Unix(1, 0)); res == nil || res.Status != "ingested" {
t.Fatalf("首次應直接送出:%+v", res)
}
if hits != 1 {
t.Errorf("應打一次,實際 %d", hits)
}
}
func TestSyncFolderTree失敗退避(t *testing.T) {
root := t.TempDir()
tree := buildTreeFromDisk(t, root)
cfg := &DirectConfig{CypherURL: "https://x.invalid", APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
now := time.Unix(1786900000, 0)
res := syncFolderTree(cfg, root, m, tree, false, now)
if res == nil || res.Status != "failed" {
t.Fatalf("應失敗:%+v", res)
}
if m.FolderTreeFailHash == "" || m.FolderTreeNextRetry == 0 {
t.Errorf("失敗沒記退避 ⇒ 雲端一壞就每 5 秒重撞一次:%+v", m)
}
// 退避窗口內同一份內容不重撞
if again := syncFolderTree(cfg, root, m, tree, false, now.Add(time.Minute)); again != nil {
t.Errorf("退避窗口內不該重試:%+v", again)
}
}
+31 -2
View File
@@ -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)
+4
View File
@@ -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_URLnamed-webhook 觸發完整 URL
direct R2: 監看資料夾 讀檔內容直送實例 rag_ingest_direct workflow設定走 --config JSON`)
+67
View File
@@ -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。
@@ -70,6 +91,11 @@ const MaxFailBeforeSkip = 8
// Manifest 對應一個被勾選的資料夾。
type Manifest struct {
FolderID string `json:"folder_id"`
// FolderCardHashes=每個子資料夾索引卡的內容雜湊(鍵=相對監看根的目錄路徑)。
// 冪等用:內容沒變就不重送(同 InventoryHash 的角色,只是一層變多層)。
// 資料夾消失時由 syncFolderCards 清掉對應的鍵,不讓這張表無限長大。
// `inkstone/Arcrun#146` 碎形目錄索引)
FolderCardHashes map[string]string `json:"folder_card_hashes,omitempty"`
Root string `json:"root"`
Entries map[string]*ManifestEntry `json:"entries"`
// InventoryHash=最後一次**成功送達雲端**的資料夾總覽卡內容雜湊(結構先行,
@@ -96,6 +122,26 @@ type Manifest struct {
// 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它,
// 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。
PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"`
// CloudAuditAt=這個資料夾最後一次跑雲端對帳批次的時間(unix 秒)。
// 純節流用(cloudAuditFolderInterval):daemon 預設 5 秒一輪,沒有這道閘
// 就是每 5 秒對雲端發 20 個請求。與 InventoryHash 同為 Manifest 層欄位,
// Scan() 的 rebuild 只重建 Entries,天然跨輪存活。
CloudAuditAt int64 `json:"cloud_audit_at,omitempty"`
// ── InkStoneCo#44 線 A:資料夾樹的冪等記帳(見 foldertree.go)───────────────
// FolderTreeHash=最後一次**成功送達雲端**的整棵樹內容雜湊;
// FolderTreeFailHashFolderTreeNextRetry=上一次送失敗的內容與退避到期時間。
// 存在理由與上面 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 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。
@@ -181,6 +227,27 @@ func (m *Manifest) MarkIngestedBy(path, sourceHash string, at int64, extractor s
// 成功即清掉失敗狀態(t195):下次再壞會從第一階退避重新算起。
e.FailCount, e.LastFailAt, e.NextRetry = 0, 0, 0
e.LastError = ""
// #140:預設「這次有送卡上雲」;真的一張卡都沒送的那條路由呼叫端補打
// MarkNoCloudCard(見 direct.go 的 cards 為空分支)。預設值放這裡而不是
// 讓呼叫端每次都設,是因為漏設的方向要落在**安全的那一邊**:
// 誤標 false ⇒ 頂多多對一次帳(一個唯讀請求);誤標 true ⇒ 這個檔從此
// 不再被對帳,雲端掉了也沒人發現——那正是本票在修的病。
e.NoCloudCard = false
// 🔴 CloudCheckedAtCloudMissingAt **刻意不清**
// 前者是輪值排序的依據,後者是防重送迴圈的 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
}
+75
View File
@@ -139,6 +139,31 @@ type TriggerPayload struct {
// 而畫面上的數字是 **0**。講一個 0 跟安靜地少收,對使用者是同一件事。
ExcludedDirs []ExcludedDir `json:"-"` // 已排序,上限 MaxExcludedDirsListed
ExcludedDirCount int `json:"-"` // 總數(可能大於清單長度)
// ── InkStoneCo#44 線 Aportal 的資料夾樹(leo 2026-08-17)─────────────────
// DirStats=走訪時**逐目錄**數出來的分母(key=相對監看根的路徑,`""`=根)。
// AllExcludedDirs=整棵被剪掉的目錄,**未裁切**(上面那個 ExcludedDirs 為了畫面
// 只留 20 筆;樹要畫得完整需要全量,不然「哪些沒收」會缺角)。
//
// 🔴 為什麼分母一定要在這裡數、不能另外走一趟:判斷「支不支援/收不收」的那一整套
// 判準就活在這個 WalkDir 裡(allowedExtdocLikeExtPlan.KeepsFileTemplateOwns)。
// 另外走一趟=同一件事第二份實作,必然漂移 ⇒ 畫面上的數字與實際收的檔對不起來,
// 而 leo 要的正是「兩個數字的差額解釋得了」。
// 同 `json:"-"`:給本機與 portal 用的,不進 collector-trigger schema。
DirStats map[string]*dirStat `json:"-"`
AllExcludedDirs []ExcludedDir `json:"-"`
}
// dirStat=一個目錄「這一層直接放的檔案」的分類計數(不含子目錄;子樹合計由畫面自己疊,
// 存兩套就是同一件事兩份實作,遲早對不起來)。
//
// 🔴 不變式:`total == supported + unsupported + excluded`。
// 少一類就會出現「兩個數字的差額解釋不了」,而那正是 leo 這條規格要解掉的病。
type dirStat struct {
total int // 這一層看得到的檔案總數(分母)
supported int // 通過所有閘、進了 manifest 的(=分子的候選)
unsupported int // 副檔名還讀不了的(docLikeExt 與其他)
excluded int // 收檔策略/範本身分決定不收的(程式碼多半落這裡)
}
// MaxExcludedDirsListed:最多逐筆列幾個被跳過的目錄。超過的只反映在 ExcludedDirCount
@@ -304,6 +329,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// 整棵剪掉的目錄與理由。**剪掉的重點就是不走進去**,所以這裡記的是目錄不是檔案數
// ——使用者要知道的本來就是「哪幾個資料夾沒收、為什麼」。見 ExcludedDir 的說明。
var excludedDirs []ExcludedDir
// InkStoneCo#44 線 A:逐目錄的分母。與上面幾個計數器同一趟走訪算出來——
// 判準只有這一份,畫面上的數字才可能與實際收的檔對得起來。
dirStats := map[string]*dirStat{"": {}} // 根一定存在(空資料夾也要有節點,arcrun-rag#106
statOf := func(relSlash string) *dirStat {
dir := folderOfRel(relSlash)
st, ok := dirStats[dir]
if !ok {
st = &dirStat{}
dirStats[dir] = st
}
return st
}
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
@@ -333,6 +370,11 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
excludedDirs = append(excludedDirs, ExcludedDir{Path: rel, Reason: why})
return filepath.SkipDir
}
// #44 線 A:走得進去的目錄一律登記,**就算它一個檔都沒有**
// ——leo 的規格是「不管那層有沒有文件,整棵樹都要採」。
if _, ok := dirStats[rel]; !ok {
dirStats[rel] = &dirStat{}
}
}
}
return nil
@@ -342,6 +384,12 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
}
if rel, ok := relOf(); ok && !opts.Plan.KeepsFile(rel) {
excludedByPlan++
// #44 線 A:這是「使用者看得到、但我們決定不收」的檔(程式碼多半落在這裡)。
// 它**要算進分母**——leo 的規格就是「兩個數字不相等是正常的,差額=不支援」,
// 而分母裡沒有它,差額就永遠解釋不了。
s := statOf(rel)
s.total++
s.excluded++
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
@@ -365,6 +413,18 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
}
}
ext := strings.ToLower(filepath.Ext(name))
// #44 線 A:走到這裡的都是「使用者自己的、看得見的」檔——上面三道
// (隱藏檔/SkipPaths 的機器檔/TemplateOwns 我們自己鋪的範本)刻意不算進分母,
// 理由與 scan.go 既有註解同一條:**我們自己鋪的東西不該佔用他的注意力**。
if rel, ok := relOf(); ok {
s := statOf(rel)
s.total++
if allowedExt[ext] {
s.supported++
} else {
s.unsupported++
}
}
if !allowedExt[ext] {
// G-6.2**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
@@ -556,6 +616,15 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// ⇒ 畫面只剩 humanizeFailure 的最後退路「當時沒有記下原因」。
// 這正是上面那句警告的第二次實例——真因是我們自己刪掉的,不是沒記。
ne.LastError = carry.LastError
// 🔴 #1402026-08-26):雲端對帳的三個欄位同樣要 carry,而且漏了會**很貴**:
// - CloudCheckedAt 歸零 ⇒ 每輪都判「太久沒對帳」⇒ 每輪重問整批(請求風暴)
// - CloudMissingAt 歸零 ⇒ **防重送迴圈的 grace 消失** ⇒ 補送 → 下一輪又拔章
// → 再補送…把使用者的 AI 額度燒光(票上明文禁止的「把一個 bug 換成另一個」)
// - NoCloudCard 歸零 ⇒ 沒卡可送的檔每天被重萃一次,永遠停不下來
// 這正是上面那句警告(t195LastError 5fcc139)的第三次實例,所以照著它走。
ne.CloudCheckedAt = carry.CloudCheckedAt
ne.CloudMissingAt = carry.CloudMissingAt
ne.NoCloudCard = carry.NoCloudCard
}
newEntries[p] = ne
}
@@ -574,6 +643,10 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
// 排序=畫面每輪穩定;裁切前先記總數,不然「等 N 個」會少報。
sort.Slice(excludedDirs, func(i, j int) bool { return excludedDirs[i].Path < excludedDirs[j].Path })
excludedDirCount := len(excludedDirs)
// #44 線 A:樹要畫得完整,需要**未裁切**的全量(下面那個裁切是給狀態列用的)。
// 先複製再裁切——共用同一個底層陣列的話,裁切會讓樹跟著少一截。
allExcludedDirs := make([]ExcludedDir, len(excludedDirs))
copy(allExcludedDirs, excludedDirs)
if len(excludedDirs) > MaxExcludedDirsListed {
excludedDirs = excludedDirs[:MaxExcludedDirsListed]
}
@@ -592,5 +665,7 @@ func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
ExcludedByPlan: excludedByPlan,
ExcludedDirs: excludedDirs,
ExcludedDirCount: excludedDirCount,
DirStats: dirStats,
AllExcludedDirs: allExcludedDirs,
}, nil
}
+21
View File
@@ -52,6 +52,23 @@ type RetiringStatus struct {
LastError string `json:"last_error,omitempty"` // 最後一次失敗的真因(原文,不改寫)
}
// ResyncStatus=某個監看資料夾的「雲端補送」現況(`inkstone/arcrun-rag#140`)。
//
// 🔴 為什麼一定要有畫面:這張票的病不只是「該送的沒送」,還有
// 「**沒有任何地方會說話**」——使用者看到檔案在資料夾裡、AI 卻查不到,
// 而且查不出為什麼。所以修法不能靜悄悄地重送:他要看得見
// 「有 N 份在補送、不是你弄壞的、你不必做任何事」。
//
// 與 RetiringFolderPlans 同族:**每輪照 manifest 現況重算的快照**
// 不進 CarryForwardActivity——補完就自然歸零,不必有人去清它。
type ResyncStatus struct {
Pending int `json:"pending"` // 章已拔掉、還沒補送成功的份數
Repaired int `json:"repaired"` // 最近一天內已補送成功的份數
CheckedAt string `json:"checked_at,omitempty"` // 最近一次對帳時間(RFC3339)
Note string `json:"note,omitempty"` // 給使用者看的一句人話
LastError string `json:"last_error,omitempty"` // 對帳本身失敗的真因(原文,不改寫)
}
// SyncStatus 彙總每輪同步的萃取結果,持久化至 ~/.arcrun-rag/status.json。
// 托盤依此決定顯示「已萃 N 檔」、「⚠ 萃取失敗 M 檔」還是「⚠ 萃取引擎未就緒」。
type SyncStatus struct {
@@ -83,6 +100,10 @@ type SyncStatus struct {
// 與 SkippedDocs 同族——每輪照現況重算的快照,不進 CarryForwardActivity。
Retiring map[string]RetiringStatus `json:"retiring,omitempty"`
// `inkstone/arcrun-rag#140`:雲端上找不到、正在自動補送的資料夾(key=資料夾路徑)。
// 與 Retiring 同族的現況快照,見 ResyncStatus 註解。
Resync map[string]ResyncStatus `json:"resync,omitempty"`
// 🔴 G-6.2「不准安靜地略過」(2026-08-06):副檔名不在 allowedExt 的檔案,
// 以前在 scan.go 的白名單閘就 `return nil` 蒸發了——沒事件、沒紀錄、沒畫面。
// 使用者丟一份 .doc 進資料夾,得到的回應是**完全的沉默**。
+95
View File
@@ -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,內層說 500unreachable。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"
)
// triggerEnvelopenamed-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 + ",稍後會自動再試。"
}
}
+161
View File
@@ -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,預期 %vbody=%s", c.name, got, c.fail, c.body)
}
}
}
// 🔴 這支是本票的核心迴歸閘:**「已送達」這個章,只能在東西真的寫進去時才准蓋。**
// 沒有它,同一個假綠會以任何一種新的包裝再回來一次
//2026-08-26 實錄:26 份蓋章、雲端 4 份,而畫面全綠)。
func TestWiring_雲端說沒寫進去就不准蓋已送達的章(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n內容"), 0o644); err != nil {
t.Fatal(err)
}
var triggers int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return
}
_, _ = io.ReadAll(r.Body)
triggers++
// 就是實例當天回的那一份:200 + 外層綠、內層紅
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, realFailingEnvelope)
}))
defer srv.Close()
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
manifestPath := filepath.Join(t.TempDir(), "m.json")
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: manifestPath,
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
}
results, exit, _ := RunDirectOnce(cfg, false)
if triggers == 0 {
t.Fatal("測試沒打到觸發端點,這支測試等於沒驗到東西")
}
if exit == 0 {
t.Errorf("一份都沒真的寫進去,exit 不該是 0:%+v", results)
}
_, fileResults := splitInventory(results)
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
t.Fatalf("那份檔應該是 failed,不是「已送達」:%+v", fileResults)
}
if !strings.Contains(fileResults[0].Error, "沒有真的寫進去") {
t.Errorf("失敗理由要講人話:%q", fileResults[0].Error)
}
// 最關鍵的一格:manifest 不准留下「已送達」的章——留了,content_hash 沒變就永遠不會重送。
m, err := LoadManifest(manifestPath, root)
if err != nil {
t.Fatalf("讀 manifest%v", err)
}
for rel, e := range m.Entries {
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
t.Errorf("%s 蓋了「已送達」章,但雲端根本沒收到 ⇒ 這份知識會永久消失", rel)
}
}
}
// 太大的檔:明知送出去一定失敗,就不要送——而且理由要是人話。
func TestTooBigForWorkersAI_講人話且不送出去(t *testing.T) {
if why := tooBigForWorkersAI(strings.Repeat("a", 1000), "小檔.md"); why != "" {
t.Errorf("一般大小的檔不該被擋:%q", why)
}
big := strings.Repeat("字", maxWorkersAIExtractBytes) // 中文一字 3 bytes ⇒ 一定超過
why := tooBigForWorkersAI(big, "mistakes.md")
if why == "" {
t.Fatal("超過上限的檔要被擋下")
}
if !strings.Contains(why, "萬字") || !strings.Contains(why, "拆成") {
t.Errorf("要講出多大、以及使用者能做什麼:%q", why)
}
for _, bad := range []string{"token", "131000", "HTTP", "8007", "llama"} {
if strings.Contains(why, bad) {
t.Errorf("訊息裸露技術細節 %q%q", bad, why)
}
}
}
func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
root := t.TempDir()
big := strings.Repeat("字", maxWorkersAIExtractBytes)
if err := os.WriteFile(filepath.Join(root, "巨檔.md"), []byte("# 巨檔\n\n"+big), 0o644); err != nil {
t.Fatal(err)
}
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": "{}"})
}))
defer srv.Close()
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md")
if err == nil {
t.Fatal("太大的檔應該直接失敗")
}
if hits != 0 {
t.Errorf("擋下的檔不該還打雲端一次(燒額度+佔佇列),實際打了 %d 次", hits)
}
if !strings.Contains(err.Error(), "太大") {
t.Errorf("理由要講人話:%v", err)
}
}