// collector — hash 差異偵測 collector(SDD ingest-hash-trigger,Go 版)。 // // 用法: // // collector scan --root <知識資料夾> --manifest \ // [--max-removed-ratio 0.4] [--dry-run] // collector upload --root <知識資料夾> --manifest \ // [--max-removed-ratio 0.4] [--dry-run] // // scan:走訪 root、對照 manifest、把事件(符合 schemas/collector-trigger.v1.schema.json) // 以 JSON 輸出到 stdout,並更新 manifest(--dry-run 不寫)。 // // upload(task 3)=scan+把 added/modified 的原稿上傳 R2(content-addressed, // key=raw/,design §4)。設定走環境變數 CF_ACCOUNT_ID / CF_API_TOKEN / // R2_BUCKET(絕不落 repo);--dry-run 只列出會上傳的 key(planned),不碰網路不寫 manifest。 // 任一上傳失敗=exit 1(manifest 照存:content_hash 反映現況、ingested_hash 不動=可重試)。 // // daemon 常駐(launchd)/打 named-webhook 觸發 ingest=之後的 task(SDD task 4、產品化段)。 package main import ( "encoding/json" "flag" "fmt" "os" "path/filepath" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "scan": os.Exit(run(os.Args[2:], false)) case "upload": os.Exit(run(os.Args[2:], true)) default: usage() os.Exit(2) } } func usage() { fmt.Fprintln(os.Stderr, `用法: collector scan --root --manifest [--max-removed-ratio 0.4] [--dry-run] collector upload --root --manifest [--max-removed-ratio 0.4] [--dry-run] upload 需環境變數: CF_ACCOUNT_ID / CF_API_TOKEN / R2_BUCKET`) } // run 是 scan/upload 共用主體;withUpload=true 時掃描後把 added/modified 原稿上傳 R2。 func run(args []string, withUpload bool) 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 模式另列 planned 上傳清單),不更新 manifest、不碰網路") if err := fs.Parse(args); err != nil { return 2 } if *root == "" || *manifestPath == "" { fmt.Fprintln(os.Stderr, "錯誤:--root 與 --manifest 皆為必填") return 2 } // upload 模式先驗設定(fail fast:缺 env 連掃都不掃,不留半套狀態)。 var client *R2Client if withUpload && !*dryRun { cfg, err := LoadR2ConfigFromEnv() if err != nil { fmt.Fprintln(os.Stderr, "collector:", err) return 2 } client = NewR2Client(cfg) } 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) } payload, err := Scan(absRoot, m, ScanOptions{ MaxRemovedRatio: *ratio, SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己 }) if err != nil { return fail(err) } var uploads []UploadResult if 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) } } if !*dryRun { if err := m.Save(absManifest); err != nil { return fail(err) } } exitCode := 0 var out any = payload if withUpload { out = struct { Trigger *TriggerPayload `json:"trigger"` Uploads []UploadResult `json:"uploads"` }{payload, uploads} for _, r := range uploads { if r.Status == "failed" { exitCode = 1 // 有敗=非零退出;manifest 已存、ingested_hash 未動=下輪自然重試 } } } 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 }