ScuttleBot

feat: persist agent last_seen across server restarts - Add last_seen column to agents table (SQLite migration) - AgentUpsert/AgentList read and write last_seen - Touch() persists to disk at most once per minute (avoids thrashing) - Agents now show accurate last_seen after server restart - Fix TestList to expect all agents including revoked Closes #50

lmata 2026-04-03 22:14 trunk
Commit 66d18d7cb0c2b3fc77fa71add009a3341fe2590bc4dbd7b8e99e58c8e8bf5b84
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -127,10 +127,11 @@
127127
Channels: cfg.Channels,
128128
Permissions: cfg.Permissions,
129129
Config: cfg,
130130
CreatedAt: row.CreatedAt,
131131
Revoked: row.Revoked,
132
+ LastSeen: row.LastSeen,
132133
}
133134
r.agents[a.Nick] = a
134135
}
135136
return nil
136137
}
@@ -144,10 +145,11 @@
144145
Nick: a.Nick,
145146
Type: string(a.Type),
146147
Config: cfg,
147148
CreatedAt: a.CreatedAt,
148149
Revoked: a.Revoked,
150
+ LastSeen: a.LastSeen,
149151
})
150152
return
151153
}
152154
r.save()
153155
}
@@ -372,21 +374,25 @@
372374
r.mu.RLock()
373375
defer r.mu.RUnlock()
374376
return r.get(nick)
375377
}
376378
377
-// Touch updates the last-seen timestamp for an agent.
379
+// Touch updates the last-seen timestamp for an agent. Persists to disk
380
+// at most once per minute to avoid thrashing on frequent heartbeats.
378381
func (r *Registry) Touch(nick string) {
379382
r.mu.Lock()
380383
defer r.mu.Unlock()
381384
a, ok := r.agents[nick]
382385
if !ok || a.Revoked {
383386
return
384387
}
385388
now := time.Now()
389
+ shouldPersist := a.LastSeen == nil || now.Sub(*a.LastSeen) >= time.Minute
386390
a.LastSeen = &now
387
- // Don't persist every heartbeat — just keep in memory.
391
+ if shouldPersist {
392
+ r.saveOne(a)
393
+ }
388394
}
389395
390396
const defaultOnlineTimeout = 2 * time.Minute
391397
392398
// SetOnlineTimeout configures how long since last_seen before an agent
393399
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -127,10 +127,11 @@
127 Channels: cfg.Channels,
128 Permissions: cfg.Permissions,
129 Config: cfg,
130 CreatedAt: row.CreatedAt,
131 Revoked: row.Revoked,
 
132 }
133 r.agents[a.Nick] = a
134 }
135 return nil
136 }
@@ -144,10 +145,11 @@
144 Nick: a.Nick,
145 Type: string(a.Type),
146 Config: cfg,
147 CreatedAt: a.CreatedAt,
148 Revoked: a.Revoked,
 
149 })
150 return
151 }
152 r.save()
153 }
@@ -372,21 +374,25 @@
372 r.mu.RLock()
373 defer r.mu.RUnlock()
374 return r.get(nick)
375 }
376
377 // Touch updates the last-seen timestamp for an agent.
 
378 func (r *Registry) Touch(nick string) {
379 r.mu.Lock()
380 defer r.mu.Unlock()
381 a, ok := r.agents[nick]
382 if !ok || a.Revoked {
383 return
384 }
385 now := time.Now()
 
386 a.LastSeen = &now
387 // Don't persist every heartbeat — just keep in memory.
 
 
388 }
389
390 const defaultOnlineTimeout = 2 * time.Minute
391
392 // SetOnlineTimeout configures how long since last_seen before an agent
393
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -127,10 +127,11 @@
127 Channels: cfg.Channels,
128 Permissions: cfg.Permissions,
129 Config: cfg,
130 CreatedAt: row.CreatedAt,
131 Revoked: row.Revoked,
132 LastSeen: row.LastSeen,
133 }
134 r.agents[a.Nick] = a
135 }
136 return nil
137 }
@@ -144,10 +145,11 @@
145 Nick: a.Nick,
146 Type: string(a.Type),
147 Config: cfg,
148 CreatedAt: a.CreatedAt,
149 Revoked: a.Revoked,
150 LastSeen: a.LastSeen,
151 })
152 return
153 }
154 r.save()
155 }
@@ -372,21 +374,25 @@
374 r.mu.RLock()
375 defer r.mu.RUnlock()
376 return r.get(nick)
377 }
378
379 // Touch updates the last-seen timestamp for an agent. Persists to disk
380 // at most once per minute to avoid thrashing on frequent heartbeats.
381 func (r *Registry) Touch(nick string) {
382 r.mu.Lock()
383 defer r.mu.Unlock()
384 a, ok := r.agents[nick]
385 if !ok || a.Revoked {
386 return
387 }
388 now := time.Now()
389 shouldPersist := a.LastSeen == nil || now.Sub(*a.LastSeen) >= time.Minute
390 a.LastSeen = &now
391 if shouldPersist {
392 r.saveOne(a)
393 }
394 }
395
396 const defaultOnlineTimeout = 2 * time.Minute
397
398 // SetOnlineTimeout configures how long since last_seen before an agent
399
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -168,12 +168,23 @@
168168
if err := r.Revoke("b"); err != nil {
169169
t.Fatalf("Revoke: %v", err)
170170
}
171171
172172
agents := r.List()
173
- if len(agents) != 2 {
174
- t.Errorf("List: got %d agents, want 2 (revoked should be excluded)", len(agents))
173
+ // 3 registered (a, b, c), b revoked — List returns all including revoked.
174
+ registered := []string{"a", "b", "c"}
175
+ if len(agents) != len(registered) {
176
+ t.Errorf("List: got %d agents, want %d", len(agents), len(registered))
177
+ }
178
+ var revokedCount int
179
+ for _, a := range agents {
180
+ if a.Revoked {
181
+ revokedCount++
182
+ }
183
+ }
184
+ if revokedCount != 1 {
185
+ t.Errorf("List: got %d revoked, want 1", revokedCount)
175186
}
176187
}
177188
178189
func TestEngagementConfigValidation(t *testing.T) {
179190
tests := []struct {
180191
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -168,12 +168,23 @@
168 if err := r.Revoke("b"); err != nil {
169 t.Fatalf("Revoke: %v", err)
170 }
171
172 agents := r.List()
173 if len(agents) != 2 {
174 t.Errorf("List: got %d agents, want 2 (revoked should be excluded)", len(agents))
 
 
 
 
 
 
 
 
 
 
 
175 }
176 }
177
178 func TestEngagementConfigValidation(t *testing.T) {
179 tests := []struct {
180
--- internal/registry/registry_test.go
+++ internal/registry/registry_test.go
@@ -168,12 +168,23 @@
168 if err := r.Revoke("b"); err != nil {
169 t.Fatalf("Revoke: %v", err)
170 }
171
172 agents := r.List()
173 // 3 registered (a, b, c), b revoked — List returns all including revoked.
174 registered := []string{"a", "b", "c"}
175 if len(agents) != len(registered) {
176 t.Errorf("List: got %d agents, want %d", len(agents), len(registered))
177 }
178 var revokedCount int
179 for _, a := range agents {
180 if a.Revoked {
181 revokedCount++
182 }
183 }
184 if revokedCount != 1 {
185 t.Errorf("List: got %d revoked, want 1", revokedCount)
186 }
187 }
188
189 func TestEngagementConfigValidation(t *testing.T) {
190 tests := []struct {
191
--- internal/store/store.go
+++ internal/store/store.go
@@ -19,10 +19,11 @@
1919
Nick string
2020
Type string
2121
Config []byte // JSON-encoded EngagementConfig
2222
CreatedAt time.Time
2323
Revoked bool
24
+ LastSeen *time.Time
2425
}
2526
2627
// AdminRow is the flat database representation of an admin account.
2728
type AdminRow struct {
2829
Username string
@@ -84,35 +85,47 @@
8485
`CREATE TABLE IF NOT EXISTS policies (
8586
id INTEGER PRIMARY KEY,
8687
data TEXT NOT NULL
8788
)`,
8889
}
90
+ // Run base schema.
8991
for _, stmt := range stmts {
9092
if _, err := s.db.Exec(stmt); err != nil {
9193
return fmt.Errorf("migrate: %w", err)
9294
}
9395
}
96
+ // Additive migrations — safe to re-run.
97
+ addColumns := []string{
98
+ `ALTER TABLE agents ADD COLUMN last_seen TEXT`,
99
+ }
100
+ for _, stmt := range addColumns {
101
+ _, _ = s.db.Exec(stmt) // ignore "column already exists"
102
+ }
94103
return nil
95104
}
96
-
97105
// AgentUpsert inserts or updates an agent row by nick.
98106
func (s *Store) AgentUpsert(r *AgentRow) error {
99107
revoked := 0
100108
if r.Revoked {
101109
revoked = 1
102110
}
111
+ var lastSeen string
112
+ if r.LastSeen != nil {
113
+ lastSeen = r.LastSeen.UTC().Format(time.RFC3339Nano)
114
+ }
103115
q := fmt.Sprintf(
104
- `INSERT INTO agents (nick, type, config, created_at, revoked)
105
- VALUES (%s, %s, %s, %s, %s)
116
+ `INSERT INTO agents (nick, type, config, created_at, revoked, last_seen)
117
+ VALUES (%s, %s, %s, %s, %s, %s)
106118
ON CONFLICT(nick) DO UPDATE SET
107119
type=EXCLUDED.type, config=EXCLUDED.config,
108
- created_at=EXCLUDED.created_at, revoked=EXCLUDED.revoked`,
109
- s.ph(1), s.ph(2), s.ph(3), s.ph(4), s.ph(5),
120
+ created_at=EXCLUDED.created_at, revoked=EXCLUDED.revoked,
121
+ last_seen=EXCLUDED.last_seen`,
122
+ s.ph(1), s.ph(2), s.ph(3), s.ph(4), s.ph(5), s.ph(6),
110123
)
111124
_, err := s.db.Exec(q,
112125
r.Nick, r.Type, string(r.Config),
113
- r.CreatedAt.UTC().Format(time.RFC3339), revoked,
126
+ r.CreatedAt.UTC().Format(time.RFC3339), revoked, lastSeen,
114127
)
115128
return err
116129
}
117130
118131
// AgentDelete removes an agent row entirely.
@@ -124,29 +137,34 @@
124137
return err
125138
}
126139
127140
// AgentList returns all agent rows, including revoked ones.
128141
func (s *Store) AgentList() ([]*AgentRow, error) {
129
- rows, err := s.db.Query(`SELECT nick, type, config, created_at, revoked FROM agents`)
142
+ rows, err := s.db.Query(`SELECT nick, type, config, created_at, revoked, COALESCE(last_seen,'') FROM agents`)
130143
if err != nil {
131144
return nil, err
132145
}
133146
defer rows.Close()
134147
135148
var out []*AgentRow
136149
for rows.Next() {
137150
var r AgentRow
138
- var cfg, ts string
151
+ var cfg, ts, lastSeenStr string
139152
var revokedInt int
140
- if err := rows.Scan(&r.Nick, &r.Type, &cfg, &ts, &revokedInt); err != nil {
153
+ if err := rows.Scan(&r.Nick, &r.Type, &cfg, &ts, &revokedInt, &lastSeenStr); err != nil {
141154
return nil, err
142155
}
143156
r.Config = []byte(cfg)
144157
r.Revoked = revokedInt != 0
145158
r.CreatedAt, err = time.Parse(time.RFC3339, ts)
146159
if err != nil {
147160
return nil, fmt.Errorf("store: agent %s timestamp: %w", r.Nick, err)
161
+ }
162
+ if lastSeenStr != "" {
163
+ if t, err := time.Parse(time.RFC3339Nano, lastSeenStr); err == nil {
164
+ r.LastSeen = &t
165
+ }
148166
}
149167
out = append(out, &r)
150168
}
151169
return out, rows.Err()
152170
}
153171
--- internal/store/store.go
+++ internal/store/store.go
@@ -19,10 +19,11 @@
19 Nick string
20 Type string
21 Config []byte // JSON-encoded EngagementConfig
22 CreatedAt time.Time
23 Revoked bool
 
24 }
25
26 // AdminRow is the flat database representation of an admin account.
27 type AdminRow struct {
28 Username string
@@ -84,35 +85,47 @@
84 `CREATE TABLE IF NOT EXISTS policies (
85 id INTEGER PRIMARY KEY,
86 data TEXT NOT NULL
87 )`,
88 }
 
89 for _, stmt := range stmts {
90 if _, err := s.db.Exec(stmt); err != nil {
91 return fmt.Errorf("migrate: %w", err)
92 }
93 }
 
 
 
 
 
 
 
94 return nil
95 }
96
97 // AgentUpsert inserts or updates an agent row by nick.
98 func (s *Store) AgentUpsert(r *AgentRow) error {
99 revoked := 0
100 if r.Revoked {
101 revoked = 1
102 }
 
 
 
 
103 q := fmt.Sprintf(
104 `INSERT INTO agents (nick, type, config, created_at, revoked)
105 VALUES (%s, %s, %s, %s, %s)
106 ON CONFLICT(nick) DO UPDATE SET
107 type=EXCLUDED.type, config=EXCLUDED.config,
108 created_at=EXCLUDED.created_at, revoked=EXCLUDED.revoked`,
109 s.ph(1), s.ph(2), s.ph(3), s.ph(4), s.ph(5),
 
110 )
111 _, err := s.db.Exec(q,
112 r.Nick, r.Type, string(r.Config),
113 r.CreatedAt.UTC().Format(time.RFC3339), revoked,
114 )
115 return err
116 }
117
118 // AgentDelete removes an agent row entirely.
@@ -124,29 +137,34 @@
124 return err
125 }
126
127 // AgentList returns all agent rows, including revoked ones.
128 func (s *Store) AgentList() ([]*AgentRow, error) {
129 rows, err := s.db.Query(`SELECT nick, type, config, created_at, revoked FROM agents`)
130 if err != nil {
131 return nil, err
132 }
133 defer rows.Close()
134
135 var out []*AgentRow
136 for rows.Next() {
137 var r AgentRow
138 var cfg, ts string
139 var revokedInt int
140 if err := rows.Scan(&r.Nick, &r.Type, &cfg, &ts, &revokedInt); err != nil {
141 return nil, err
142 }
143 r.Config = []byte(cfg)
144 r.Revoked = revokedInt != 0
145 r.CreatedAt, err = time.Parse(time.RFC3339, ts)
146 if err != nil {
147 return nil, fmt.Errorf("store: agent %s timestamp: %w", r.Nick, err)
 
 
 
 
 
148 }
149 out = append(out, &r)
150 }
151 return out, rows.Err()
152 }
153
--- internal/store/store.go
+++ internal/store/store.go
@@ -19,10 +19,11 @@
19 Nick string
20 Type string
21 Config []byte // JSON-encoded EngagementConfig
22 CreatedAt time.Time
23 Revoked bool
24 LastSeen *time.Time
25 }
26
27 // AdminRow is the flat database representation of an admin account.
28 type AdminRow struct {
29 Username string
@@ -84,35 +85,47 @@
85 `CREATE TABLE IF NOT EXISTS policies (
86 id INTEGER PRIMARY KEY,
87 data TEXT NOT NULL
88 )`,
89 }
90 // Run base schema.
91 for _, stmt := range stmts {
92 if _, err := s.db.Exec(stmt); err != nil {
93 return fmt.Errorf("migrate: %w", err)
94 }
95 }
96 // Additive migrations — safe to re-run.
97 addColumns := []string{
98 `ALTER TABLE agents ADD COLUMN last_seen TEXT`,
99 }
100 for _, stmt := range addColumns {
101 _, _ = s.db.Exec(stmt) // ignore "column already exists"
102 }
103 return nil
104 }
 
105 // AgentUpsert inserts or updates an agent row by nick.
106 func (s *Store) AgentUpsert(r *AgentRow) error {
107 revoked := 0
108 if r.Revoked {
109 revoked = 1
110 }
111 var lastSeen string
112 if r.LastSeen != nil {
113 lastSeen = r.LastSeen.UTC().Format(time.RFC3339Nano)
114 }
115 q := fmt.Sprintf(
116 `INSERT INTO agents (nick, type, config, created_at, revoked, last_seen)
117 VALUES (%s, %s, %s, %s, %s, %s)
118 ON CONFLICT(nick) DO UPDATE SET
119 type=EXCLUDED.type, config=EXCLUDED.config,
120 created_at=EXCLUDED.created_at, revoked=EXCLUDED.revoked,
121 last_seen=EXCLUDED.last_seen`,
122 s.ph(1), s.ph(2), s.ph(3), s.ph(4), s.ph(5), s.ph(6),
123 )
124 _, err := s.db.Exec(q,
125 r.Nick, r.Type, string(r.Config),
126 r.CreatedAt.UTC().Format(time.RFC3339), revoked, lastSeen,
127 )
128 return err
129 }
130
131 // AgentDelete removes an agent row entirely.
@@ -124,29 +137,34 @@
137 return err
138 }
139
140 // AgentList returns all agent rows, including revoked ones.
141 func (s *Store) AgentList() ([]*AgentRow, error) {
142 rows, err := s.db.Query(`SELECT nick, type, config, created_at, revoked, COALESCE(last_seen,'') FROM agents`)
143 if err != nil {
144 return nil, err
145 }
146 defer rows.Close()
147
148 var out []*AgentRow
149 for rows.Next() {
150 var r AgentRow
151 var cfg, ts, lastSeenStr string
152 var revokedInt int
153 if err := rows.Scan(&r.Nick, &r.Type, &cfg, &ts, &revokedInt, &lastSeenStr); err != nil {
154 return nil, err
155 }
156 r.Config = []byte(cfg)
157 r.Revoked = revokedInt != 0
158 r.CreatedAt, err = time.Parse(time.RFC3339, ts)
159 if err != nil {
160 return nil, fmt.Errorf("store: agent %s timestamp: %w", r.Nick, err)
161 }
162 if lastSeenStr != "" {
163 if t, err := time.Parse(time.RFC3339Nano, lastSeenStr); err == nil {
164 r.LastSeen = &t
165 }
166 }
167 out = append(out, &r)
168 }
169 return out, rows.Err()
170 }
171

Keyboard Shortcuts

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