feat: three-state sync planner with warning semantics

- Plan is a pure function (inventory, manifest, disk facts, injected path
  mapping) emitting exactly six actions: deploy, update, skip+warn,
  leave+warn, remove, keep — deterministic order, no filesystem access
- full 3x3 matrix (disk unmodified/user-modified/never-managed x upstream
  added/changed/removed) table-tested plus missing-disk restore cases,
  a mixed fixture and a purity/determinism guarantee test
- executor applies plans item by item (atomic deploy/remove per item),
  rewrites the manifest once, appends timestamped SKIP/LEAVE lines with
  item name and reason to the sync log
- sync now plans before mutating: unmanaged occupants block new deploys,
  upstream deletions remove only unmodified copies, local modifications
  always win and are warned about
This commit is contained in:
2026-08-23 10:36:44 +02:00
parent 49dbcf469e
commit 63619b90a4
7 changed files with 751 additions and 101 deletions
+145
View File
@@ -0,0 +1,145 @@
package client
import "sort"
// Action is one step of a sync plan.
type Action string
// The six sync actions. skip warns about managed items with local
// modifications; leave warns about self-created items agent-lib never
// managed; remove only ever touches unmodified managed copies.
const (
ActDeploy Action = "deploy"
ActUpdate Action = "update"
ActSkip Action = "skip+warn"
ActLeave Action = "leave+warn"
ActRemove Action = "remove"
ActKeep Action = "keep"
)
// PlannedItem is one deployable inventory item with its repo-side hash.
type PlannedItem struct {
Type string
Name string
Origin string
RelPath string
Hash string
}
// DiskFact is the observed on-disk state of one deployed path.
type DiskFact struct {
Exists bool
Hash string
}
// PlanEntry is one decided action with everything the executor needs.
type PlanEntry struct {
Action Action
Type string
Name string
Origin string
RelPath string
DstPath string
NewHash string
Reason string
}
// Plan is a pure function from (inventory, manifest, on-disk facts) to an
// action plan. pathFor injects the target path mapping so the planner never
// touches the filesystem or the environment. The plan is sorted by
// (type, name) for deterministic execution and reporting.
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string) []PlanEntry {
invByKey := map[string]PlannedItem{}
for _, it := range items {
invByKey[manifestKey(it.Type, it.Name)] = it
}
var plan []PlanEntry
emit := func(a Action, it PlannedItem, reason string) {
e := PlanEntry{Action: a, Reason: reason, NewHash: it.Hash}
e.Type, e.Name, e.Origin, e.RelPath = it.Type, it.Name, it.Origin, it.RelPath
e.DstPath = pathFor(e.Type, e.Name)
plan = append(plan, e)
}
keys := map[string]bool{}
for key := range invByKey {
keys[key] = true
}
for key := range manifest.Items {
keys[key] = true
}
for key, fact := range disk {
if fact.Exists {
keys[key] = true
}
}
sorted := make([]string, 0, len(keys))
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
for _, key := range sorted {
it, inInv := invByKey[key]
rec := manifest.Items[key]
fact := disk[key]
switch {
case inInv && rec == nil:
if fact.Exists {
emit(ActSkip, it, "path occupied by a local item agent-lib never deployed; deploy blocked")
} else {
emit(ActDeploy, it, "")
}
case inInv && it.Hash == rec.Hash:
decideAgainstDisk(it, rec, fact, emit, "unchanged")
case inInv:
decideAgainstDisk(it, rec, fact, emit, "changed")
case rec != nil && !fact.Exists:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "already gone from disk; dropping manifest record")
case rec != nil && fact.Hash == rec.Hash:
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted")
case rec != nil:
emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin},
"upstream deleted but the local copy was modified; keeping it")
default:
emit(ActLeave, PlannedItem{Type: typeOfKey(key), Name: nameOfKey(key)},
"unmanaged local item; left untouched")
}
}
return plan
}
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string) {
switch {
case !fact.Exists:
emit(ActUpdate, it, "managed item missing on disk; restoring")
case fact.Hash == rec.Hash:
if upstreamState == "unchanged" {
emit(ActKeep, it, "")
} else {
emit(ActUpdate, it, "")
}
default:
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
}
}
func typeOfKey(key string) string {
for i := len(key) - 1; i >= 0; i-- {
if key[i] == '/' {
return key[:i]
}
}
return key
}
func nameOfKey(key string) string {
for i := len(key) - 1; i >= 0; i-- {
if key[i] == '/' {
return key[i+1:]
}
}
return key
}