feat(ingest-hash-trigger): collector R2 content-addressed 原稿上傳(SDD task 3)
- collector upload 子命令=scan+把 added/modified 原稿傳 R2(CF REST API Bearer token,key=raw/<sha256hex>,對齊 collector-trigger.v1 的 r2_key) - 冪等:存在檢查命中=skipped_exists 不 PUT;CF API objects 端點不支援 HEAD(live 實測 405)→ 改 GET+Range: bytes=0-0 - 完整性:上傳前重算 hash 核對 key,不符=failed 不上傳 - 失敗語意:failed → exit 1,ingested_hash 不動=下輪自動重試; 回寫鉤子 Manifest.MarkIngested 留給 task 4 - 設定只走環境變數 CF_ACCOUNT_ID/CF_API_TOKEN/R2_BUCKET,不落 repo - go test 14/14 綠(httptest mock 對齊真 API:HEAD 405);live e2e 全通 (arcrun-rag-raw-demo:真上傳→重傳 no-op→下載 diff 一致+sha256==key) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
// collector — hash 差異偵測 collector(SDD ingest-hash-trigger,Go 版骨架)。
|
||||
// collector — hash 差異偵測 collector(SDD ingest-hash-trigger,Go 版)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// collector scan --root <知識資料夾> --manifest <manifest.json 路徑> \
|
||||
// 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]
|
||||
//
|
||||
// 一次掃描:走訪 root、對照 manifest、把事件(符合
|
||||
// schemas/collector-trigger.v1.schema.json)以 JSON 輸出到 stdout,並更新 manifest
|
||||
// (--dry-run 不寫)。daemon 常駐/launchd/R2 上傳/打 named-webhook=之後的 task,
|
||||
// 本階段不接網路。
|
||||
// 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/<sha256hex>,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 (
|
||||
@@ -20,55 +27,117 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 || os.Args[1] != "scan" {
|
||||
fmt.Fprintln(os.Stderr, "用法: collector scan --root <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]")
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
fs := flag.NewFlagSet("scan", flag.ExitOnError)
|
||||
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 <dir> --manifest <file> [--max-removed-ratio 0.4] [--dry-run]
|
||||
collector upload --root <dir> --manifest <file> [--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, "只輸出事件,不更新 manifest")
|
||||
if err := fs.Parse(os.Args[2:]); err != nil {
|
||||
os.Exit(2)
|
||||
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 皆為必填")
|
||||
os.Exit(2)
|
||||
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 {
|
||||
fatal(err)
|
||||
return fail(err)
|
||||
}
|
||||
absManifest, err := filepath.Abs(*manifestPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
m, err := LoadManifest(absManifest, absRoot)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return fail(err)
|
||||
}
|
||||
payload, err := Scan(absRoot, m, ScanOptions{
|
||||
MaxRemovedRatio: *ratio,
|
||||
SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己
|
||||
})
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return fail(err)
|
||||
}
|
||||
if !*dryRun {
|
||||
if err := m.Save(absManifest); err != nil {
|
||||
fatal(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)
|
||||
}
|
||||
}
|
||||
out, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
|
||||
if !*dryRun {
|
||||
if err := m.Save(absManifest); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
|
||||
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 fatal(err error) {
|
||||
func fail(err error) int {
|
||||
fmt.Fprintln(os.Stderr, "collector:", err)
|
||||
os.Exit(1)
|
||||
return 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user