Files
arcrun-collector/manifest.go
T
Leo bb96bceb04 feat(ingest-hash-trigger): 凍結 collector/ingest schema+Go collector 骨架(issue #5, SDD task 1+2)
- schemas/collector-trigger.v1.schema.json:collector→named-webhook 觸發 payload(source_hash/r2_key/renamed/R6 warnings)
- schemas/kbdb-ingest-request.v1.schema.json:source_hash 必填缺=400;儲存映射走 metadata_json.$.source_hash(守 kbdb 表不變鐵律);同 hash 重送=200 already_ingested
- schemas/MIGRATION-webhook-payload.md:舊 Gitea push payload 逐欄對照(給 task 4 改寫 workflow)
- collector/:Go module(純 stdlib)——manifest 讀寫+mtime fast-path 掃描+renamed hash 配對+40% 大量刪除防呆;CLI collector scan
- go test ./... 全綠 7/7(added/modified/removed/renamed/防呆五情境+fast-path+manifest 往返)
- Node 版 collector 標 legacy 並存(SDD task 4 拆除)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:31:11 +08:00

104 lines
2.9 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.
// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。
// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。
package main
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// ManifestEntry 是單一檔案在 manifest 裡的狀態。
// - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。
// - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」;
// 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash
// 留給之後的上傳/webhook task 在成功後回寫。
type ManifestEntry struct {
ContentHash string `json:"content_hash"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
IngestedHash string `json:"ingested_hash,omitempty"`
IngestedAt int64 `json:"ingested_at,omitempty"`
}
// Manifest 對應一個被勾選的資料夾。
type Manifest struct {
FolderID string `json:"folder_id"`
Root string `json:"root"`
Entries map[string]*ManifestEntry `json:"entries"`
}
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。
func newUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。
func LoadManifest(path, root string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil
}
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err)
}
if m.Entries == nil {
m.Entries = map[string]*ManifestEntry{}
}
if m.FolderID == "" {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
m.FolderID = id
}
if root != "" {
m.Root = root
}
return &m, nil
}
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
func (m *Manifest) Save(path string) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}