Files
agent-lib/internal/client/executor.go
T
m3ta-chiron 949a431c4c feat: status command + windows toast notifications
- status: read-only report of deployed/repository revision, pending changes,
  skipped items (with reasons), unrecognized local items, binary version;
  --json machine-readable; exit 1 when local modifications block repository
  updates (headless drift checks)
- sync/status share a read-only prepare() front half
- notify package: windows toast via PowerShell script (go-toast mechanism,
  zero deps, build-tag gated), noop on linux/macos; cross-compile verified
- toast fires exactly once per sync with warnings, never for routine syncs;
  notification failures are logged and never fail the sync (fail-open)
2026-08-23 10:40:25 +02:00

174 lines
4.9 KiB
Go

package client
import (
"fmt"
"io"
"os"
"time"
"github.com/m3tam3re/agent-lib/internal/deploy"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
"github.com/m3tam3re/agent-lib/internal/notify"
"github.com/m3tam3re/agent-lib/internal/target"
)
// Notifier delivers user-facing notifications; swap it in tests. Failures
// never fail the sync (fail-open, logged to stdout).
var Notifier notify.Sender = notify.Platform()
// SyncReport summarizes one planner-driven sync run.
type SyncReport struct {
Revision string
Deployed int
Updated int
Kept int
Removed int
Skipped int
Left int
Manifest string
}
// ExecutePlan applies a plan item by item: deploys and removes are atomic
// per item, every skip+warn and leave+warn is appended to the warning log
// with item name and reason, and the manifest is rewritten once at the end.
func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest, paths StatePaths, rev string, stdout io.Writer) (*SyncReport, error) {
report := &SyncReport{Revision: rev, Manifest: paths.Manifest}
var warnings []string
for _, e := range plan {
key := manifestKey(e.Type, e.Name)
switch e.Action {
case ActDeploy, ActUpdate:
if err := deployEntry(tree, e); err != nil {
return nil, fmt.Errorf("%s %s/%s: %w", e.Action, e.Type, e.Name, err)
}
manifest.Items[key] = &ManifestItem{
Name: e.Name, Type: e.Type, Origin: e.Origin, Revision: rev, Hash: e.NewHash,
}
if e.Action == ActDeploy {
report.Deployed++
} else {
report.Updated++
}
case ActKeep:
report.Kept++
case ActRemove:
if err := os.RemoveAll(e.DstPath); err != nil {
return nil, fmt.Errorf("removing %s/%s: %w", e.Type, e.Name, err)
}
delete(manifest.Items, key)
report.Removed++
case ActSkip:
warnings = append(warnings, fmt.Sprintf("SKIP %s/%s: %s", e.Type, e.Name, e.Reason))
report.Skipped++
case ActLeave:
warnings = append(warnings, fmt.Sprintf("LEAVE %s/%s: %s", e.Type, e.Name, e.Reason))
report.Left++
}
}
manifest.Version = ManifestVersion
manifest.Revision = rev
if err := manifest.Save(paths.Manifest); err != nil {
return nil, err
}
if len(warnings) > 0 {
if err := appendWarnings(paths.LogFile, warnings); err != nil {
fmt.Fprintf(stdout, "warning: could not write sync log: %v\n", err)
}
title := "agent-lib sync"
body := fmt.Sprintf("%d item(s) kept local: modifications blocked company updates. See the sync log for details.", report.Skipped+report.Left)
if err := Notifier.Send(title, body); err != nil {
fmt.Fprintf(stdout, "warning: notification failed: %v\n", err)
}
}
printPlannedSyncReport(stdout, report)
return report, nil
}
func deployEntry(tree *gitsource.GitTree, e PlanEntry) error {
if e.Type == lockfile.TypeSkill {
return deploy.Dir(tree, e.RelPath, e.DstPath)
}
return deploy.File(tree, e.RelPath, e.DstPath)
}
// appendWarnings adds timestamped warning lines to the sync log.
func appendWarnings(logPath string, warnings []string) error {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
stamp := time.Now().UTC().Format(time.RFC3339)
for _, w := range warnings {
if _, err := fmt.Fprintf(f, "%s %s\n", stamp, w); err != nil {
return err
}
}
return nil
}
// diskFacts observes every deployed path the planner can care about:
// manifest items, current inventory items, and unmanaged occupants of the
// target directories.
func diskFacts(oc *target.OpenCode, manifest *Manifest, items []PlannedItem) map[string]DiskFact {
facts := map[string]DiskFact{}
observe := func(typ, name string) {
key := manifestKey(typ, name)
if _, seen := facts[key]; seen {
return
}
path, ok := oc.DeployPath(typ, name)
if !ok {
return
}
if _, err := os.Stat(path); err != nil {
facts[key] = DiskFact{}
return
}
hash, err := HashDiskPath(typ, path)
if err != nil {
hash = ""
}
facts[key] = DiskFact{Exists: true, Hash: hash}
}
for _, rec := range manifest.Items {
observe(rec.Type, rec.Name)
}
for _, it := range items {
observe(it.Type, it.Name)
}
for key := range oc.ScanTargetDirs() {
if _, seen := facts[key]; !seen {
facts[key] = DiskFact{Exists: true}
}
}
return facts
}
func printPlannedSyncReport(w io.Writer, r *SyncReport) {
fmt.Fprintf(w, "synced to revision %s\n", shortRev(r.Revision))
fmt.Fprintf(w, " deployed: %d\n", r.Deployed)
fmt.Fprintf(w, " updated: %d\n", r.Updated)
fmt.Fprintf(w, " kept: %d\n", r.Kept)
fmt.Fprintf(w, " removed: %d\n", r.Removed)
if r.Skipped > 0 {
fmt.Fprintf(w, " skipped (local modifications kept): %d\n", r.Skipped)
}
if r.Left > 0 {
fmt.Fprintf(w, " unmanaged items left untouched: %d\n", r.Left)
}
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
}
func shortRev(rev string) string {
if len(rev) > 12 {
return rev[:12]
}
return rev
}