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)
This commit is contained in:
@@ -9,9 +9,14 @@ import (
|
||||
"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
|
||||
@@ -72,6 +77,11 @@ func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest,
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||
)
|
||||
|
||||
type countingNotifier struct {
|
||||
calls int
|
||||
titles []string
|
||||
failWith error
|
||||
}
|
||||
|
||||
func (c *countingNotifier) Send(title, body string) error {
|
||||
c.calls++
|
||||
c.titles = append(c.titles, title)
|
||||
return c.failWith
|
||||
}
|
||||
|
||||
func skipPlanEntry(t *testing.T) PlanEntry {
|
||||
return PlanEntry{
|
||||
Action: ActSkip, Type: "skills", Name: "alpha",
|
||||
RelPath: "skills/alpha", DstPath: filepath.Join(t.TempDir(), "skills", "alpha"),
|
||||
NewHash: "sha256:x", Reason: "locally modified; keeping the local version (upstream changed)",
|
||||
}
|
||||
}
|
||||
|
||||
func executeForTest(t *testing.T, plan []PlanEntry) (*SyncReport, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
paths := StatePaths{Root: dir, Manifest: filepath.Join(dir, "manifest.json"), LogFile: filepath.Join(dir, "sync.log")}
|
||||
manifest := manifestWith(map[string]*ManifestItem{
|
||||
"skills/alpha": {Name: "alpha", Type: "skills", Hash: "sha256:old"},
|
||||
})
|
||||
var tree *gitsource.GitTree
|
||||
return ExecutePlan(tree, plan, manifest, paths, "rev123", os.Stdout)
|
||||
}
|
||||
|
||||
func TestToastFiredOnceForWarnings(t *testing.T) {
|
||||
orig := Notifier
|
||||
t.Cleanup(func() { Notifier = orig })
|
||||
fake := &countingNotifier{}
|
||||
Notifier = fake
|
||||
|
||||
_, err := executeForTest(t, []PlanEntry{skipPlanEntry(t), {
|
||||
Action: ActLeave, Type: "skills", Name: "mine", Reason: "unmanaged local item; left untouched",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.calls != 1 {
|
||||
t.Errorf("exactly one toast per sync with warnings, got %d", fake.calls)
|
||||
}
|
||||
if len(fake.titles) != 1 || fake.titles[0] != "agent-lib sync" {
|
||||
t.Errorf("unexpected toast titles: %v", fake.titles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoToastForRoutineSync(t *testing.T) {
|
||||
orig := Notifier
|
||||
t.Cleanup(func() { Notifier = orig })
|
||||
fake := &countingNotifier{}
|
||||
Notifier = fake
|
||||
|
||||
_, err := executeForTest(t, []PlanEntry{{
|
||||
Action: ActKeep, Type: "skills", Name: "alpha",
|
||||
RelPath: "skills/alpha", DstPath: filepath.Join(t.TempDir(), "skills", "alpha"),
|
||||
NewHash: "sha256:x",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("routine sync must stay silent, got %d toasts", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastFailureNeverFailsSync(t *testing.T) {
|
||||
orig := Notifier
|
||||
t.Cleanup(func() { Notifier = orig })
|
||||
Notifier = &countingNotifier{failWith: errors.New("no interactive session")}
|
||||
|
||||
report, err := executeForTest(t, []PlanEntry{skipPlanEntry(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("toast failure must not fail the sync: %v", err)
|
||||
}
|
||||
if report.Skipped != 1 {
|
||||
t.Errorf("skip must still be applied: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(t.TempDir())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEntryFieldCoverage(t *testing.T) {
|
||||
e := skipPlanEntry(t)
|
||||
if e.Action != ActSkip || e.Type != lockfile.TypeSkill || e.Reason == "" {
|
||||
t.Fatalf("plan entry incomplete: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/version"
|
||||
)
|
||||
|
||||
// StatusEntry names one item in a status section.
|
||||
type StatusEntry struct {
|
||||
Item string `json:"item"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// StatusReport is the machine-readable local-state report.
|
||||
type StatusReport struct {
|
||||
Version string `json:"agent_lib_version"`
|
||||
DeployedRev string `json:"deployed_revision"`
|
||||
RepoRev string `json:"repository_revision"`
|
||||
InSync bool `json:"in_sync"`
|
||||
Drift bool `json:"problematic_drift"`
|
||||
Pending []StatusEntry `json:"pending_changes,omitempty"`
|
||||
Skipped []StatusEntry `json:"skipped_items,omitempty"`
|
||||
Unrecognized []StatusEntry `json:"unrecognized_items,omitempty"`
|
||||
}
|
||||
|
||||
// Status reports the local state without mutating anything. Drift is true
|
||||
// when local modifications block repository updates (or occupy names the
|
||||
// repository needs) — the state headless drift checks flag with a non-zero
|
||||
// exit code.
|
||||
func Status(opts SyncOptions) (*StatusReport, error) {
|
||||
p, err := prepare(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
report := &StatusReport{
|
||||
Version: version.Version,
|
||||
DeployedRev: p.manifest.Revision,
|
||||
RepoRev: p.pull.Rev,
|
||||
}
|
||||
for _, e := range p.plan {
|
||||
item := e.Type + "/" + e.Name
|
||||
switch e.Action {
|
||||
case ActDeploy, ActUpdate, ActRemove:
|
||||
report.Pending = append(report.Pending, StatusEntry{Item: item, Reason: string(e.Action)})
|
||||
case ActSkip:
|
||||
blocked := true
|
||||
if rec := p.manifest.Items[manifestKey(e.Type, e.Name)]; rec != nil && rec.Hash == e.NewHash {
|
||||
blocked = false
|
||||
}
|
||||
if blocked {
|
||||
report.Drift = true
|
||||
}
|
||||
report.Skipped = append(report.Skipped, StatusEntry{Item: item, Reason: e.Reason})
|
||||
case ActLeave:
|
||||
report.Unrecognized = append(report.Unrecognized, StatusEntry{Item: item, Reason: e.Reason})
|
||||
}
|
||||
}
|
||||
report.InSync = report.DeployedRev == report.RepoRev && len(report.Pending) == 0
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// PrintStatus renders the human-readable report and returns the drift
|
||||
// verdict so the caller can set the exit code.
|
||||
func PrintStatus(w io.Writer, r *StatusReport) bool {
|
||||
fmt.Fprintf(w, "agent-lib %s\n", r.Version)
|
||||
fmt.Fprintf(w, "deployed revision: %s\n", revOrNone(r.DeployedRev))
|
||||
fmt.Fprintf(w, "repository revision: %s\n", revOrNone(r.RepoRev))
|
||||
if r.InSync {
|
||||
fmt.Fprintln(w, "state: in sync")
|
||||
} else {
|
||||
fmt.Fprintln(w, "state: out of sync")
|
||||
}
|
||||
printSection := func(title string, entries []StatusEntry) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s:\n", title)
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, " %s", e.Item)
|
||||
if e.Reason != "" {
|
||||
fmt.Fprintf(w, " — %s", e.Reason)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
printSection("pending changes", r.Pending)
|
||||
printSection("skipped (local modifications)", r.Skipped)
|
||||
printSection("unrecognized local items", r.Unrecognized)
|
||||
if r.Drift {
|
||||
fmt.Fprintln(w, "drift: local modifications block repository updates")
|
||||
}
|
||||
return r.Drift
|
||||
}
|
||||
|
||||
func revOrNone(rev string) string {
|
||||
if rev == "" {
|
||||
return "(never synced)"
|
||||
}
|
||||
return rev
|
||||
}
|
||||
+25
-6
@@ -15,11 +15,19 @@ type SyncOptions struct {
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// Sync pulls the work repository, plans the sync as a pure function over
|
||||
// (inventory, manifest, disk facts) and executes the plan. Validation and
|
||||
// hashing happen before the first mutation; unreachable repositories fail
|
||||
// cleanly with the previous state intact.
|
||||
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
|
||||
// prepared is everything sync and status share: the pulled repository, the
|
||||
// planned action list, and the local state — before any mutation.
|
||||
type prepared struct {
|
||||
paths StatePaths
|
||||
pull *PullResult
|
||||
manifest *Manifest
|
||||
items []PlannedItem
|
||||
plan []PlanEntry
|
||||
}
|
||||
|
||||
// prepare pulls, inventories, hashes and plans — the complete read-only
|
||||
// front half of a client run.
|
||||
func prepare(opts SyncOptions) (*prepared, error) {
|
||||
cfg, err := LoadClientConfig(opts.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -71,8 +79,19 @@ func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
|
||||
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
|
||||
}
|
||||
}
|
||||
return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil
|
||||
}
|
||||
|
||||
return ExecutePlan(pull.Tree, plan, manifest, paths, pull.Rev, stdout)
|
||||
// Sync pulls the work repository, plans the sync as a pure function over
|
||||
// (inventory, manifest, disk facts) and executes the plan. Validation and
|
||||
// hashing happen before the first mutation; unreachable repositories fail
|
||||
// cleanly with the previous state intact.
|
||||
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
|
||||
p, err := prepare(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExecutePlan(p.pull.Tree, p.plan, p.manifest, p.paths, p.pull.Rev, stdout)
|
||||
}
|
||||
|
||||
// safePath rejects destinations that escape the user's home directory.
|
||||
|
||||
Reference in New Issue
Block a user