feat: client sync core — config, pull, inventory, manifest, OpenCode target
- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default), token used for basic auth only, redacted from all errors and files - work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on config change), clean failure keeps previous state - inventory: own items + external areas from lockfile; MCP inventoried but never deployed - manifest v1 records name/type/origin/revision/content-hash per item, deterministic bytes; folder hashes over sorted relpath+filehash lines so tree and disk hashes agree - shared deploy package (atomic temp+rename, exec-bit preserving) now backs both curator materialization and client deployment - sync validates and hashes everything before the first mutation; second run is a byte-level no-op
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||
)
|
||||
|
||||
// ManifestVersion is the local manifest schema version.
|
||||
const ManifestVersion = 1
|
||||
|
||||
// ManifestItem records one deployed item: name, type, origin, the work-repo
|
||||
// revision it came from, and the content hash at deploy time.
|
||||
type ManifestItem struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Origin string `json:"origin"`
|
||||
Revision string `json:"revision"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// Manifest is the local record of everything agent-lib deployed.
|
||||
type Manifest struct {
|
||||
Version int `json:"version"`
|
||||
Revision string `json:"revision"`
|
||||
Items map[string]*ManifestItem `json:"items"`
|
||||
}
|
||||
|
||||
// NewManifest returns an empty manifest v1.
|
||||
func NewManifest() *Manifest {
|
||||
return &Manifest{Version: ManifestVersion, Items: map[string]*ManifestItem{}}
|
||||
}
|
||||
|
||||
// LoadManifest reads the manifest at path; a missing file yields an empty
|
||||
// manifest, not an error (first sync).
|
||||
func LoadManifest(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewManifest(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest %s is not valid JSON: %w", path, err)
|
||||
}
|
||||
if m.Items == nil {
|
||||
m.Items = map[string]*ManifestItem{}
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Save writes the manifest deterministically (sorted item keys, two-space
|
||||
// indent, trailing newline).
|
||||
func (m *Manifest) Save(path string) error {
|
||||
data, err := marshalManifest(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func marshalManifest(m *Manifest) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// Key identifies an item in the manifest: "<type>/<name>".
|
||||
func manifestKey(typ, name string) string {
|
||||
return typ + "/" + name
|
||||
}
|
||||
|
||||
// HashItem computes the content hash of an inventory item from its git tree:
|
||||
// single files hash their bytes; folders hash "relpath 0x00 hash" lines over
|
||||
// all contained files, sorted.
|
||||
func HashItem(tree *gitsource.GitTree, item InventoryItem) (string, error) {
|
||||
if item.Type != lockfile.TypeSkill {
|
||||
data, err := tree.ReadFile(item.RelPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
files, err := tree.FilesUnder(item.RelPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := strings.TrimSuffix(item.RelPath, "/") + "/"
|
||||
h := sha256.New()
|
||||
for _, f := range files {
|
||||
data, err := tree.ReadFile(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fsum := sha256.Sum256(data)
|
||||
fmt.Fprintf(h, "%s\x00%s\n", strings.TrimPrefix(f, prefix), hex.EncodeToString(fsum[:]))
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// HashDiskPath computes the same content hash for a deployed path on disk.
|
||||
func HashDiskPath(typ, path string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
var lines []string
|
||||
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(path, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fsum := sha256.Sum256(data)
|
||||
lines = append(lines, fmt.Sprintf("%s\x00%s", filepath.ToSlash(rel), hex.EncodeToString(fsum[:])))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(h, "%s\n", l)
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user