From d5c78da9aec7da475d8b2da989d99de085d3255c Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Tue, 29 Sep 2026 15:01:46 +0800 Subject: [PATCH] fix: allow retiring capture hosts per brain --- README.md | 6 ++ cmd/devbrain/main.go | 5 ++ internal/heartbeat/heartbeat.go | 16 ++-- internal/heartbeat/hosts.go | 134 +++++++++++++++++++++++++++++++ internal/heartbeat/hosts_test.go | 107 ++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 internal/heartbeat/hosts.go create mode 100644 internal/heartbeat/hosts_test.go diff --git a/README.md b/README.md index 2fffd40..3948fe8 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ box the flusher wires the systemd user timer over SSH (enable-linger + user-bus env — no manual `loginctl` dance). Flip a machine's role anytime with `devbrain role satellite` / `devbrain role curator`. +Use `devbrain hosts list` to inspect capture sources in the selected brain. +`devbrain hosts retire ` silences that brain's stale warning for an +obsolete machine; `devbrain hosts restore ` reverses it. A newer +capture automatically reactivates the host. Retirement is saved in the brain's +`retired-hosts.json` and syncs on the next flush; local capture failures still warn. + **Search engine (optional).** Offline `devbrain brain search` needs nothing. For ranked search, opt into `gbrain` — a separate local search engine (a global `bun add -g`, pinned `gbrain@0.18.2`) — with `devbrain install --install-deps`. For *semantic* ranking — the diff --git a/cmd/devbrain/main.go b/cmd/devbrain/main.go index d1a04b7..bf85c8c 100644 --- a/cmd/devbrain/main.go +++ b/cmd/devbrain/main.go @@ -15,6 +15,7 @@ import ( "github.com/TheWeiHu/devbrain/internal/config" "github.com/TheWeiHu/devbrain/internal/flush" "github.com/TheWeiHu/devbrain/internal/gbrainlog" + "github.com/TheWeiHu/devbrain/internal/heartbeat" "github.com/TheWeiHu/devbrain/internal/hookev" "github.com/TheWeiHu/devbrain/internal/hooks" "github.com/TheWeiHu/devbrain/internal/jsonedit" @@ -46,6 +47,7 @@ const usage = `devbrain — prompts in, brain out devbrain project-key [cwd] print the project identity slug devbrain maintenance [pass] distill Step-8 daily gates devbrain role [curator|satellite] print or set this machine's curation role + devbrain hosts list|retire|restore manage capture hosts in the selected brain devbrain link-preferences wire the preferences @import devbrain install wire this machine (hooks, skills, dashboard) devbrain uninstall remove the wiring (data repo untouched) @@ -96,6 +98,9 @@ var commands = map[string]func(args []string) int{ return maintenance.Run(args, os.Stdout, os.Stderr) }, "role": cmdRole, + "hosts": func(args []string) int { + return heartbeat.Run(args, os.Stdout, os.Stderr) + }, } // cmdRole prints or sets the machine's curation role. Satellites capture, diff --git a/internal/heartbeat/heartbeat.go b/internal/heartbeat/heartbeat.go index 99f7425..cb4eac6 100644 --- a/internal/heartbeat/heartbeat.go +++ b/internal/heartbeat/heartbeat.go @@ -19,9 +19,9 @@ import ( ) const ( - staleAfter = 48 * time.Hour // silent longer than this -> warn - activeWindow = 60 * 24 * time.Hour // silent longer than this -> retired, stop warning - scanLimit = "500" // capture commits to scan; ~weeks of history + staleAfter = 48 * time.Hour // silent longer than this -> warn + activeWindow = 60 * 24 * time.Hour // silent longer than this -> retired, stop warning + scanLimit = "500" // capture commits to scan; ~weeks of history ) // Now is swappable for tests. @@ -35,10 +35,11 @@ type Host struct { // Status is the health of the data repo's capture flow. type Status struct { - Wedged bool // unmerged files: flush aborts until resolved - Self *Host // this machine's entry, if it has ever captured + Wedged bool // unmerged files: flush aborts until resolved + Self *Host // this machine's entry, if it has ever captured SelfStale bool StaleOthers []Host // other active hosts gone silent + Hosts []Host } // Check inspects the data repo. Git failures degrade to a healthy Status — @@ -71,8 +72,10 @@ func Check(dataDir string) Status { } } self := selfHost() + retired, _ := readRetired(dataDir) now := Now() for name, when := range newest { + st.Hosts = append(st.Hosts, Host{Name: name, Last: when}) age := now.Sub(when) if name == self { h := Host{Name: name, Last: when} @@ -80,6 +83,9 @@ func Check(dataDir string) Status { st.SelfStale = age > staleAfter continue } + if last, ok := retired[name]; ok && !when.After(last) { + continue + } if age > staleAfter && age <= activeWindow { st.StaleOthers = append(st.StaleOthers, Host{Name: name, Last: when}) } diff --git a/internal/heartbeat/hosts.go b/internal/heartbeat/hosts.go new file mode 100644 index 0000000..0378ed8 --- /dev/null +++ b/internal/heartbeat/hosts.go @@ -0,0 +1,134 @@ +package heartbeat + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/TheWeiHu/devbrain/internal/config" +) + +const retiredFile = "retired-hosts.json" + +// Record the last capture, not retirement time: any newer capture revives a host. +func readRetired(dataDir string) (map[string]time.Time, error) { + m := map[string]time.Time{} + b, err := os.ReadFile(filepath.Join(dataDir, retiredFile)) + if errors.Is(err, os.ErrNotExist) { + return m, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + if m == nil { + m = map[string]time.Time{} + } + return m, nil +} + +func writeRetired(dataDir string, m map[string]time.Time) error { + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + f, err := os.CreateTemp(dataDir, ".retired-hosts-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.Write(append(b, '\n')); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), filepath.Join(dataDir, retiredFile)) +} + +// Run manages capture sources in the selected brain only. +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 || (args[0] == "list" && len(args) != 1) || + ((args[0] == "retire" || args[0] == "restore") && len(args) != 2) || + (args[0] != "list" && args[0] != "retire" && args[0] != "restore") { + fmt.Fprintln(stderr, "usage: devbrain hosts list | retire | restore ") + return 2 + } + dataDir, err := config.ResolveDataDir() + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + m, err := readRetired(dataDir) + if err != nil { + fmt.Fprintf(stderr, "hosts: read %s: %v\n", retiredFile, err) + return 1 + } + st := Check(dataDir) + if args[0] == "list" { + known := map[string]Host{} + for name, last := range m { + known[name] = Host{Name: name, Last: last} + } + for _, h := range st.Hosts { + known[h.Name] = h + } + names := make([]string, 0, len(known)) + for name := range known { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + h := known[name] + state := "active" + if Now().Sub(h.Last) > staleAfter { + state = "stale" + } + if Now().Sub(h.Last) > activeWindow && name != selfHost() { + state = "aged out" + } + if last, ok := m[name]; ok && !h.Last.After(last) && name != selfHost() { + state = "retired" + } + fmt.Fprintf(stdout, "%s\t%s\tlast capture %s\n", name, state, h.Last.UTC().Format(time.RFC3339)) + } + return 0 + } + name := args[1] + if args[0] == "retire" { + if name == selfHost() { + fmt.Fprintln(stderr, "hosts: cannot retire this machine; local capture warnings remain enabled") + return 1 + } + found := false + for _, h := range st.Hosts { + if h.Name == name { + m[name], found = h.Last, true + } + } + if _, ok := m[name]; !found && !ok { + fmt.Fprintf(stderr, "hosts: unknown capture host %q; use hosts list\n", name) + return 1 + } + } else { + if _, ok := m[name]; !ok { + fmt.Fprintf(stderr, "hosts: %q is not retired\n", name) + return 1 + } + delete(m, name) + } + if err := writeRetired(dataDir, m); err != nil { + fmt.Fprintf(stderr, "hosts: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "%s: %s in %s\n", args[0], name, dataDir) + return 0 +} diff --git a/internal/heartbeat/hosts_test.go b/internal/heartbeat/hosts_test.go new file mode 100644 index 0000000..d85e9ce --- /dev/null +++ b/internal/heartbeat/hosts_test.go @@ -0,0 +1,107 @@ +package heartbeat + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/TheWeiHu/devbrain/internal/gitx" +) + +func selectTestBrain(t *testing.T, dir string) { + t.Helper() + base := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", base) + t.Setenv("DEVBRAIN_DATA", dir) + t.Setenv("DEVBRAIN_BRAIN", "") + if err := os.MkdirAll(filepath.Join(base, "devbrain"), 0o700); err != nil { + t.Fatal(err) + } + b, err := json.Marshal(map[string]any{"brains": map[string]string{"test": dir}, "default_brain": "test"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "devbrain", "config.json"), b, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRetirementIsReversibleAndScopedToBrain(t *testing.T) { + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + Now = func() time.Time { return now } + defer func() { Now = time.Now }() + entries := []Host{{Name: "old-box", Last: now.Add(-7 * 24 * time.Hour)}} + dir, other := repoWith(t, entries), repoWith(t, entries) + selectTestBrain(t, dir) + var out, errs bytes.Buffer + if code := Run([]string{"retire", "old-box"}, &out, &errs); code != 0 { + t.Fatalf("retire: %d %s", code, &errs) + } + if Warning(dir) != "" || Warning(other) == "" { + t.Fatal("retirement must silence only the selected brain") + } + out.Reset() + if Run([]string{"list"}, &out, &errs) != 0 || !strings.Contains(out.String(), "old-box\tretired") { + t.Fatalf("list: %s %s", &out, &errs) + } + if Run([]string{"restore", "old-box"}, &out, &errs) != 0 || Warning(dir) == "" { + t.Fatalf("restore must restore warning: %s", &errs) + } +} + +func TestNewCaptureRevivesRetiredHost(t *testing.T) { + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + Now = func() time.Time { return now } + defer func() { Now = time.Now }() + last := now.Add(-7 * 24 * time.Hour) + dir := repoWith(t, []Host{{Name: "old-box", Last: last}}) + if err := writeRetired(dir, map[string]time.Time{"old-box": last}); err != nil { + t.Fatal(err) + } + repo := gitx.Repo{Dir: dir} + stamp := now.Add(-72 * time.Hour).Format(time.RFC3339) + if _, err := repo.RunEnv([]string{"GIT_COMMITTER_DATE=" + stamp, "GIT_AUTHOR_DATE=" + stamp}, + "commit", "--allow-empty", "-qm", "capture: again on old-box"); err != nil { + t.Fatal(err) + } + if !strings.Contains(Warning(dir), "old-box") { + t.Fatal("new capture must reactivate stale-host warnings") + } +} + +func TestRetirementCannotHideLocalFailureOrMalformedState(t *testing.T) { + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + Now = func() time.Time { return now } + defer func() { Now = time.Now }() + last := now.Add(-72 * time.Hour) + dir := repoWith(t, []Host{{Name: selfHost(), Last: last}}) + selectTestBrain(t, dir) + var out, errs bytes.Buffer + if Run([]string{"retire", selfHost()}, &out, &errs) == 0 { + t.Fatal("must reject local retirement") + } + if Run([]string{"retire", "typo-host"}, &out, &errs) == 0 { + t.Fatal("must reject unknown host") + } + if err := writeRetired(dir, map[string]time.Time{selfHost(): last}); err != nil { + t.Fatal(err) + } + if Warning(dir) == "" { + t.Fatal("handwritten retirement must not hide local failure") + } + p := filepath.Join(dir, retiredFile) + if err := os.WriteFile(p, []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + if Run([]string{"restore", selfHost()}, &out, &errs) == 0 || Warning(dir) == "" { + t.Fatal("malformed state must fail mutation and retain warnings") + } + b, _ := os.ReadFile(p) + if string(b) != "broken" { + t.Fatal("malformed retirement state was overwritten") + } +}