Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hostname>` silences that brain's stale warning for an
obsolete machine; `devbrain hosts restore <hostname>` 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
Expand Down
5 changes: 5 additions & 0 deletions cmd/devbrain/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -46,6 +47,7 @@ const usage = `devbrain — prompts in, brain out
devbrain project-key [cwd] print the project identity slug
devbrain maintenance <due|stamp> <project> [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)
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions internal/heartbeat/heartbeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 —
Expand Down Expand Up @@ -71,15 +72,20 @@ 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}
st.Self = &h
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})
}
Expand Down
134 changes: 134 additions & 0 deletions internal/heartbeat/hosts.go
Original file line number Diff line number Diff line change
@@ -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 <hostname> | restore <hostname>")
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
}
107 changes: 107 additions & 0 deletions internal/heartbeat/hosts_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading