Files
arcrun-collector/main.go
T
Leo 97309b5580 fix(collector): 排除判準不看版控、不誤殺筆記庫、剪掉什麼講得出來(arcrun-rag#104)
票上寫的真兇是錯的。`direct.go` 那一行 `skipDirNames{"system-dev"}` 不是唯一的
排除清單——同一個 Scan 呼叫下面幾行就是 `Plan: plan`,#104 的清單一直都接著。
拿 leo 真實的 `pms` 唯讀跑一輪現行 main:策略 docs-only、送 9 個檔、node_modules 零個。
他 08-16 看到 undici 文件,是因為手上的 daemon 是 v0.18.27,而修法 cc6e500 要到
v0.18.28(08-16 16:13,7f379d0)才被戳版號——那支 commit 自己就寫著
「changelog 停在 v0.18.27,而 collector/ 早已往前走 30 個檔(…cc6e500…)」。

但那個誤判之所以會發生,是因為底下有四個真的缺陷,這一版把它們一起修掉:

① 兩張表分居兩處 ⇒ 讀源碼的人只看得到一張。
   `system-dev` 的保護搬進 IngestPlan(templateOwnedDirNames),
   direct.go 不再手捏第二張清單。判準只剩一個地方。

② 排除規則生不生效,取決於呼叫端記不記得傳 Plan。
   改成 Scan 自己算(Mode == "" ⇒ PlanIngest)。「忘了接」這個失敗模式不存在了。

③ 一張大表把「沒有人會這樣命名」與「這是普通英文字」混在一起,於是**誤殺**。
   實測:一般筆記庫 8 份筆記只送出 1 份(build/樂高作品集、out/外出旅遊、
   vendor/廠商聯絡簿…全被當成建置產物),而且回報「擋掉 0 個」。
   拆成三種理由,強度不同、要求的佐證也不同:
     ① 使用者的 .gitignore 說的(新增 ignorerules.go,git 語法的安全子集)
     ② 名字本身就不是人話(node_modules、__pycache__…)——無條件
     ③ 泛用名(build/dist/out/vendor…)——**旁邊真的擺著專案檔才算**
   🔴 判準一律不看 `.git`(leo 2026-08-16:「你不需要判斷有沒有 git,
   我的 KB 筆記庫也有 git,是否用 github/gitea 追蹤完全沒意義」)。
   `.gitignore` 只讀內容當線索,不拿存在當門檻。
   順帶:鎖定檔(pnpm-lock.yaml…)不是知識——`.yaml` 進白名單後它變成了「知識」。

④ 「排除規則要看得見」只做了一半:整棵剪掉的子樹一個都沒數(pms 實測回報 0),
   而且 Plan/ExcludedByPlan 只有 CLI 讀,daemon(使用者真正走的那條路)拿到就丟。
   新增 ExcludedDirs(路徑+人話理由)+ SyncStatus.FolderPlans 寫進 status.json。

實測(唯讀跑 leo 的 `/Users/youlinhsieh/Documents/tech_projects/pms`):
  139 個文件檔 → 送出 7 個,全是他自己的 README/docs;
  6 個資料夾整棵跳過,每個都講得出理由;node_modules 與授權條款 0 個。

測試:collector 全綠(新增 12 案,含「裸呼叫 Scan 也必須排除別人的套件」、
「不准再有第二張排除清單」的源碼層守門、筆記庫不誤殺、同名看旁邊擺什麼決定);
arcrun-app 全綠。未出貨、未推 main。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:48:22 +08:00

322 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// collector — hash 差異偵測 collectorSDD ingest-hash-triggerGo 版)。
//
// 用法:
//
// collector scan --root <知識資料夾> --manifest <manifest.json 路徑> \
// [--max-removed-ratio 0.4] [--dry-run]
// collector upload --root <知識資料夾> --manifest <manifest.json 路徑> \
// [--max-removed-ratio 0.4] [--dry-run]
// collector sync --root <知識資料夾> --manifest <manifest.json 路徑> \
// [--max-removed-ratio 0.4] [--dry-run]
//
// scan:走訪 root、對照 manifest、把事件(符合 schemas/collector-trigger.v1.schema.json
// 以 JSON 輸出到 stdout,並更新 manifest--dry-run 不寫)。
//
// uploadtask 3)=scan+把 added/modified 的原稿上傳 R2content-addressed
// key=raw/<sha256hex>design §4)。設定走環境變數 CF_ACCOUNT_ID / CF_API_TOKEN /
// R2_BUCKET(絕不落 repo);--dry-run 只列出會上傳的 keyplanned),不碰網路不寫 manifest。
// 任一上傳失敗=exit 1manifest 照存:content_hash 反映現況、ingested_hash 不動=可重試)。
//
// synctask 4)=scanupload+把整輪 payload POST 到 ARCRUN_TRIGGER_URLarcrun
// named-webhook 觸發 ingest cypher workflow)。HTTP 2xx 才對本輪送出的 added/modified/
// renamed 事件回寫 Manifest.MarkIngested;失敗不回寫(下輪自然重試)。上傳失敗的事件
// 不隨 payload 送出(r2_key 語意=原稿已在 R2)。防呆警告輪照送 warnings、不含下架事件。
//
// daemon 常駐(launchd)=之後的 task(產品化段 task 11)。
package collector
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
)
type runMode struct {
withUpload bool
withTrigger bool
}
// 版本資訊(t1502026-07-29 leo:「我的和下載下來的會是同一個嗎?」)。
//
// 為什麼 collector 也要:先前**只有 tray 注入版本**build-mac.sh L34),
// collector 是另一支獨立編譯的執行檔、完全沒有版本號(L22 不帶 LDFLAGS_VER)。
// ⇒ 托盤選單顯示的是 tray 的版本,**實際幹活的 collector 可能是任何版本**——
// 選單說 v0.14.1 也證明不了 collector 有沒有含某個修復(如 t149 多帳號同步)。
// 兩支必須注入**同一個版本值**,「顯示的」才等於「跑的」。
var (
version = "dev"
buildTime = ""
)
func versionString() string {
if buildTime == "" {
return version
}
return version + " (build " + buildTime + ")"
}
// Run 是 collector 的命令列入口,回傳 exit code。
//
// 🔴 2026-08-06:這裡以前叫 main()。改成匯出函式,是為了讓桌面 App **直接呼叫**,
// 而不是「把第二支 .exe 內嵌起來、執行時攤到磁碟再跑」。
// 後者是 Windows Defender 眼中的 dropper 特徵——leo 的封測者實撞:
// 「因為檔案包含病毒或潛在的垃圾軟體,所以作業未順利完成」,檔案當場被刪。
// leo 08-06 裁決:「不要兩支,寫成一支檔案」⇒ 同一個執行檔靠參數切換身分,
// 磁碟上永遠只有一個 exe,沒有任何東西被攤出來。
func Run(args []string) int {
if len(args) < 1 {
usage()
return 2
}
switch args[0] {
case "--version", "-v", "version":
fmt.Println(versionString())
return 0
case "scan":
return run(args[1:], runMode{})
case "upload":
return run(args[1:], runMode{withUpload: true})
case "sync":
return run(args[1:], runMode{withUpload: true, withTrigger: true})
case "direct":
return runDirect(args[1:])
case "lint":
return runLint(args[1:])
case "template-install":
return runTemplateInstall(args[1:])
case "tidy":
return runTidy(args[1:])
default:
usage()
return 2
}
}
// newFlagSet 是子命令共用的 flag.FlagSet 建構子(ExitOnError=解析失敗直接退出)。
func newFlagSet() *flag.FlagSet {
return flag.NewFlagSet("collector", flag.ExitOnError)
}
func usage() {
fmt.Fprintln(os.Stderr, `用法:
collector scan --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
collector upload --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
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]
tidy: 找出以前寫進資料夾、還沒帶 arcrun- 標記的舊產物並改名/搬走(預設只列出不動手,加 --apply 才執行;從不刪檔)
upload 需環境變數: CF_ACCOUNT_ID / CF_API_TOKEN / R2_BUCKET
sync 另需: ARCRUN_TRIGGER_URLnamed-webhook 觸發完整 URL
direct(無 R2): 監看資料夾 → 讀檔內容直送實例 rag_ingest_direct workflow;設定走 --config JSON`)
}
// run 是 scan/upload/sync 共用主體。
func run(args []string, mode runMode) int {
fs := flag.NewFlagSet("collector", flag.ExitOnError)
root := fs.String("root", "", "知識資料夾根路徑(必填)")
manifestPath := fs.String("manifest", "", "manifest JSON 檔路徑(必填;不存在會建新)")
ratio := fs.Float64("max-removed-ratio", DefaultMaxRemovedRatio, "大量刪除防呆門檻(removed 數 > manifest 條目 × 本值 → 全部不下架、只發警告)")
dryRun := fs.Bool("dry-run", false, "只輸出事件(upload/sync 模式另列 planned 清單),不更新 manifest、不碰網路")
if err := fs.Parse(args); err != nil {
return 2
}
if *root == "" || *manifestPath == "" {
fmt.Fprintln(os.Stderr, "錯誤:--root 與 --manifest 皆為必填")
return 2
}
// 先驗設定(fail fast:缺 env 連掃都不掃,不留半套狀態)。
var client *R2Client
var triggerURL string
if !*dryRun {
if mode.withUpload {
cfg, err := LoadR2ConfigFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "collector:", err)
return 2
}
client = NewR2Client(cfg)
}
if mode.withTrigger {
u, err := LoadTriggerURLFromEnv()
if err != nil {
fmt.Fprintln(os.Stderr, "collector:", err)
return 2
}
triggerURL = u
}
}
absRoot, err := filepath.Abs(*root)
if err != nil {
return fail(err)
}
absManifest, err := filepath.Abs(*manifestPath)
if err != nil {
return fail(err)
}
m, err := LoadManifest(absManifest, absRoot)
if err != nil {
return fail(err)
}
// arcrun-rag#104CLI 與 daemon 走同一套收檔策略——兩邊對同一個資料夾得出不同答案
// 本身就是 bug(同 vault.go 開頭那條「安裝器與 daemon 必須一致」)。
// 這裡刻意**不**動 CLI 原有的 SkipDirNames 行為(它本來就沒有;`system-dev/` 由
// TemplateOwns 擋,curated-wiki 模式的解除也走 Plan 那條路,見 scan.go)。
plan := PlanIngest(absRoot)
payload, err := Scan(absRoot, m, ScanOptions{
MaxRemovedRatio: *ratio,
SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己
Plan: plan,
})
if err != nil {
return fail(err)
}
// 策略走 stderrstdout 是給程式吃的 payload JSON,不能污染。
// 印出來不是 debug 訊息,是 #104 的紅線——排除規則要看得見。
fmt.Fprintf(os.Stderr, "收檔策略:%s — %s\n", plan.Mode, plan.Reason)
if payload.ExcludedByPlan > 0 {
fmt.Fprintf(os.Stderr, "依這個策略跳過了 %d 個檔案。\n", payload.ExcludedByPlan)
}
// 🔴 上面那個數字只數得到「走進去了才被逐檔擋下」的檔——整棵剪掉的子樹一個都不算。
// 實測 leo 的 `pms`2,127 檔,78% 在依賴目錄底下):排除了絕大多數,這個數字卻是 0。
// ⇒ 剪掉的目錄要逐筆講,不然「看得見」只是看得見一個 0。
if payload.ExcludedDirCount > 0 {
fmt.Fprintf(os.Stderr, "整個跳過了 %d 個資料夾:\n", payload.ExcludedDirCount)
for _, d := range payload.ExcludedDirs {
fmt.Fprintf(os.Stderr, " · %s — %s\n", d.Path, d.Reason)
}
if payload.ExcludedDirCount > len(payload.ExcludedDirs) {
fmt.Fprintf(os.Stderr, " …等 %d 個。\n", payload.ExcludedDirCount)
}
}
if len(plan.OtherWikiDirs) > 0 {
fmt.Fprintf(os.Stderr,
"這個資料夾底下還有 %d 個子專案有自己的知識庫,我沒有收(要收請個別加進看守清單):%v\n",
len(plan.OtherWikiDirs), plan.OtherWikiDirs)
}
exitCode := 0
var uploads []UploadResult
if mode.withUpload {
if *dryRun {
uploads = []UploadResult{}
for _, ev := range payload.Events {
if ev.Type == "added" || ev.Type == "modified" {
uploads = append(uploads, UploadResult{Path: ev.Path, R2Key: ev.R2Key, Status: "planned"})
}
}
} else {
uploads = UploadChanged(absRoot, payload.Events, client)
for _, r := range uploads {
if r.Status == "failed" {
exitCode = 1 // 有敗=非零退出;ingested_hash 未動=下輪自然重試
}
}
}
}
// syncPOST 觸發 → 2xx 才回寫 MarkIngested(在 m.Save 之前,回寫才進得了檔)。
outPayload := payload
var dispatch *TriggerResult
if mode.withTrigger {
if *dryRun {
dispatch = &TriggerResult{Status: "planned"}
} else {
sendable, dropped := BuildSendablePayload(payload, uploads)
outPayload = sendable
dispatch = &TriggerResult{DroppedPaths: dropped}
if len(sendable.Events) == 0 && len(sendable.Warnings) == 0 {
dispatch.Status = "skipped_no_changes" // 無變更輪不發送(schema 註明空發也合法,但沒必要)
} else {
status, terr := SendTrigger(triggerURL, sendable, nil)
dispatch.HTTPStatus = status
if terr != nil {
dispatch.Status = "failed"
dispatch.Error = terr.Error()
exitCode = 1
} else {
dispatch.Status = "sent"
dispatch.MarkedCount = MarkIngestedEvents(m, sendable.Events, dropped, time.Now().Unix())
}
}
}
}
if !*dryRun {
if err := m.Save(absManifest); err != nil {
return fail(err)
}
}
var out any = outPayload
if mode.withTrigger {
out = struct {
Trigger *TriggerPayload `json:"trigger"`
Uploads []UploadResult `json:"uploads"`
Dispatch *TriggerResult `json:"dispatch"`
}{outPayload, uploads, dispatch}
} else if mode.withUpload {
out = struct {
Trigger *TriggerPayload `json:"trigger"`
Uploads []UploadResult `json:"uploads"`
}{outPayload, uploads}
}
data, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fail(err)
}
fmt.Println(string(data))
return exitCode
}
func fail(err error) int {
fmt.Fprintln(os.Stderr, "collector:", err)
return 1
}
// runLint 是 `collector lint <卡片.md>` 子命令:對一張卡跑 B2 品質檢查(H1–H6)。
// 印出分級 JSON;被擋(硬缺,或 --strict 下含軟項)=exit 1,方便腳本/CI 銷帳(施工順序步驟 4)。
func runLint(args []string) int {
fs := newFlagSet()
strict := fs.Bool("strict", false, "軟項(H3/H4/H6)也算擋(測試/CI")
source := fs.String("source", "", "原稿檔路徑(H6 相似度需要;不給=跳過 H6)")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "用法:collector lint <卡片.md> [--source <原稿>] [--strict]")
return 2
}
card, err := os.ReadFile(fs.Arg(0))
if err != nil {
return fail(err)
}
var src string
if *source != "" {
b, serr := os.ReadFile(*source)
if serr != nil {
return fail(serr)
}
src = string(b)
}
res := LintCard(string(card), LintOptions{Source: src})
blocked := res.Blocks(*strict)
out, _ := json.MarshalIndent(struct {
Card string `json:"card"`
Strict bool `json:"strict"`
Blocked bool `json:"blocked"`
Result LintResult `json:"result"`
}{fs.Arg(0), *strict, blocked, res}, "", " ")
fmt.Println(string(out))
if blocked {
return 1
}
return 0
}