Files
agent-lib/internal/client/manifest.go
T
m3ta-chiron 49dbcf469e 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
2026-08-23 10:31:00 +02:00

158 lines
4.0 KiB
Go

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
}