ScuttleBot

feat: agent presence tracking — online/offline status via last_seen Add LastSeen timestamp and Online bool to Agent. Touch() updates last_seen on registration, channel updates, and presence heartbeats. List() computes online=true when last_seen is within 2 minutes. Agents now persist after disconnect (delete_on_close=false in relay config) so operators can see connection history.

lmata 2026-04-03 19:35 trunk
Commit 1cbc747d348b8263da27f270e4b6b4923b50f84e71b325116caa306c87868737
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -55,10 +55,11 @@
5555
s.log.Error("register agent", "nick", req.Nick, "err", err)
5656
writeError(w, http.StatusInternalServerError, "registration failed")
5757
return
5858
}
5959
60
+ s.registry.Touch(req.Nick)
6061
writeJSON(w, http.StatusCreated, registerResponse{
6162
Credentials: creds,
6263
Payload: payload,
6364
})
6465
}
@@ -153,10 +154,11 @@
153154
}
154155
s.log.Error("update agent channels", "nick", nick, "err", err)
155156
writeError(w, http.StatusInternalServerError, "update failed")
156157
return
157158
}
159
+ s.registry.Touch(nick)
158160
w.WriteHeader(http.StatusNoContent)
159161
}
160162
161163
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
162164
agents := s.registry.List()
163165
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -55,10 +55,11 @@
55 s.log.Error("register agent", "nick", req.Nick, "err", err)
56 writeError(w, http.StatusInternalServerError, "registration failed")
57 return
58 }
59
 
60 writeJSON(w, http.StatusCreated, registerResponse{
61 Credentials: creds,
62 Payload: payload,
63 })
64 }
@@ -153,10 +154,11 @@
153 }
154 s.log.Error("update agent channels", "nick", nick, "err", err)
155 writeError(w, http.StatusInternalServerError, "update failed")
156 return
157 }
 
158 w.WriteHeader(http.StatusNoContent)
159 }
160
161 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
162 agents := s.registry.List()
163
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -55,10 +55,11 @@
55 s.log.Error("register agent", "nick", req.Nick, "err", err)
56 writeError(w, http.StatusInternalServerError, "registration failed")
57 return
58 }
59
60 s.registry.Touch(req.Nick)
61 writeJSON(w, http.StatusCreated, registerResponse{
62 Credentials: creds,
63 Payload: payload,
64 })
65 }
@@ -153,10 +154,11 @@
154 }
155 s.log.Error("update agent channels", "nick", nick, "err", err)
156 writeError(w, http.StatusInternalServerError, "update failed")
157 return
158 }
159 s.registry.Touch(nick)
160 w.WriteHeader(http.StatusNoContent)
161 }
162
163 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
164 agents := s.registry.List()
165
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -97,10 +97,13 @@
9797
if req.Nick == "" {
9898
writeError(w, http.StatusBadRequest, "nick is required")
9999
return
100100
}
101101
s.bridge.TouchUser(channel, req.Nick)
102
+ if s.registry != nil {
103
+ s.registry.Touch(req.Nick)
104
+ }
102105
w.WriteHeader(http.StatusNoContent)
103106
}
104107
105108
func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
106109
channel := "#" + r.PathValue("channel")
107110
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -97,10 +97,13 @@
97 if req.Nick == "" {
98 writeError(w, http.StatusBadRequest, "nick is required")
99 return
100 }
101 s.bridge.TouchUser(channel, req.Nick)
 
 
 
102 w.WriteHeader(http.StatusNoContent)
103 }
104
105 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
106 channel := "#" + r.PathValue("channel")
107
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -97,10 +97,13 @@
97 if req.Nick == "" {
98 writeError(w, http.StatusBadRequest, "nick is required")
99 return
100 }
101 s.bridge.TouchUser(channel, req.Nick)
102 if s.registry != nil {
103 s.registry.Touch(req.Nick)
104 }
105 w.WriteHeader(http.StatusNoContent)
106 }
107
108 func (s *Server) handleChannelUsers(w http.ResponseWriter, r *http.Request) {
109 channel := "#" + r.PathValue("channel")
110
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -37,10 +37,12 @@
3737
Channels []string `json:"channels"` // convenience: same as Config.Channels
3838
Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
3939
Config EngagementConfig `json:"config"`
4040
CreatedAt time.Time `json:"created_at"`
4141
Revoked bool `json:"revoked"`
42
+ LastSeen *time.Time `json:"last_seen,omitempty"`
43
+ Online bool `json:"online"`
4244
}
4345
4446
// Credentials are the SASL credentials an agent uses to connect to Ergo.
4547
type Credentials struct {
4648
Nick string `json:"nick"`
@@ -369,19 +371,34 @@
369371
r.mu.RLock()
370372
defer r.mu.RUnlock()
371373
return r.get(nick)
372374
}
373375
374
-// List returns all registered, non-revoked agents.
376
+// Touch updates the last-seen timestamp for an agent.
377
+func (r *Registry) Touch(nick string) {
378
+ r.mu.Lock()
379
+ defer r.mu.Unlock()
380
+ a, ok := r.agents[nick]
381
+ if !ok || a.Revoked {
382
+ return
383
+ }
384
+ now := time.Now()
385
+ a.LastSeen = &now
386
+ // Don't persist every heartbeat — just keep in memory.
387
+}
388
+
389
+const onlineThreshold = 2 * time.Minute
390
+
391
+// List returns all registered agents with computed online status.
375392
func (r *Registry) List() []*Agent {
376393
r.mu.RLock()
377394
defer r.mu.RUnlock()
395
+ now := time.Now()
378396
var out []*Agent
379397
for _, a := range r.agents {
380
- if !a.Revoked {
381
- out = append(out, a)
382
- }
398
+ a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < onlineThreshold
399
+ out = append(out, a)
383400
}
384401
return out
385402
}
386403
387404
func (r *Registry) get(nick string) (*Agent, error) {
388405
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -37,10 +37,12 @@
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
40 CreatedAt time.Time `json:"created_at"`
41 Revoked bool `json:"revoked"`
 
 
42 }
43
44 // Credentials are the SASL credentials an agent uses to connect to Ergo.
45 type Credentials struct {
46 Nick string `json:"nick"`
@@ -369,19 +371,34 @@
369 r.mu.RLock()
370 defer r.mu.RUnlock()
371 return r.get(nick)
372 }
373
374 // List returns all registered, non-revoked agents.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375 func (r *Registry) List() []*Agent {
376 r.mu.RLock()
377 defer r.mu.RUnlock()
 
378 var out []*Agent
379 for _, a := range r.agents {
380 if !a.Revoked {
381 out = append(out, a)
382 }
383 }
384 return out
385 }
386
387 func (r *Registry) get(nick string) (*Agent, error) {
388
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -37,10 +37,12 @@
37 Channels []string `json:"channels"` // convenience: same as Config.Channels
38 Permissions []string `json:"permissions"` // convenience: same as Config.Permissions
39 Config EngagementConfig `json:"config"`
40 CreatedAt time.Time `json:"created_at"`
41 Revoked bool `json:"revoked"`
42 LastSeen *time.Time `json:"last_seen,omitempty"`
43 Online bool `json:"online"`
44 }
45
46 // Credentials are the SASL credentials an agent uses to connect to Ergo.
47 type Credentials struct {
48 Nick string `json:"nick"`
@@ -369,19 +371,34 @@
371 r.mu.RLock()
372 defer r.mu.RUnlock()
373 return r.get(nick)
374 }
375
376 // Touch updates the last-seen timestamp for an agent.
377 func (r *Registry) Touch(nick string) {
378 r.mu.Lock()
379 defer r.mu.Unlock()
380 a, ok := r.agents[nick]
381 if !ok || a.Revoked {
382 return
383 }
384 now := time.Now()
385 a.LastSeen = &now
386 // Don't persist every heartbeat — just keep in memory.
387 }
388
389 const onlineThreshold = 2 * time.Minute
390
391 // List returns all registered agents with computed online status.
392 func (r *Registry) List() []*Agent {
393 r.mu.RLock()
394 defer r.mu.RUnlock()
395 now := time.Now()
396 var out []*Agent
397 for _, a := range r.agents {
398 a.Online = a.LastSeen != nil && now.Sub(*a.LastSeen) < onlineThreshold
399 out = append(out, a)
 
400 }
401 return out
402 }
403
404 func (r *Registry) get(nick string) (*Agent, error) {
405

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button