Implement timestamp tracking and staleness detection (bd-159, bd-160)

- bd-159: Track last_import_time in metadata after auto-import
- bd-160: Add staleness check to daemon before serving requests
  - Detects when JSONL mtime > last import time
  - Currently logs warning (needs bd-166 for actual import trigger)
- Add lastImportTime field to RPC Server struct with getter/setter
- Add checkAndAutoImportIfStale() method to detect stale JSONL

This is part of bd-158 epic to fix daemon showing stale data after git pull.

Amp-Thread-ID: https://ampcode.com/threads/T-b554e049-aff8-4a24-8bf3-3305483b7f5a
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-25 23:07:30 -07:00
parent 362d7172c0
commit daa25db720
3 changed files with 113 additions and 3 deletions

View File

@@ -86,6 +86,9 @@ type Server struct {
requestTimeout time.Duration
// Ready channel signals when server is listening
readyChan chan struct{}
// Last JSONL import timestamp (for staleness detection)
lastImportTime time.Time
importMu sync.RWMutex // protects lastImportTime
}
// NewServer creates a new RPC server
@@ -614,6 +617,17 @@ func (s *Server) handleRequest(req *Request) Response {
}
}
// Check for stale JSONL and auto-import if needed (bd-160)
// Skip for write operations that will trigger export anyway
// Skip for import operation itself to avoid recursion
if req.Operation != OpPing && req.Operation != OpHealth && req.Operation != OpMetrics &&
req.Operation != OpImport && req.Operation != OpExport {
if err := s.checkAndAutoImportIfStale(req); err != nil {
// Log warning but continue - don't fail the request
fmt.Fprintf(os.Stderr, "Warning: staleness check failed: %v\n", err)
}
}
var resp Response
switch req.Operation {
case OpPing:
@@ -2032,3 +2046,92 @@ func (s *Server) handleEpicStatus(req *Request) Response {
Data: data,
}
}
// GetLastImportTime returns the last JSONL import timestamp
func (s *Server) GetLastImportTime() time.Time {
s.importMu.RLock()
defer s.importMu.RUnlock()
return s.lastImportTime
}
// SetLastImportTime updates the last JSONL import timestamp
func (s *Server) SetLastImportTime(t time.Time) {
s.importMu.Lock()
defer s.importMu.Unlock()
s.lastImportTime = t
}
// checkAndAutoImportIfStale checks if JSONL is newer than last import and triggers auto-import
// This fixes bd-158: daemon shows stale data after git pull
func (s *Server) checkAndAutoImportIfStale(req *Request) error {
// Get storage for this request
store, err := s.getStorageForRequest(req)
if err != nil {
return fmt.Errorf("failed to get storage: %w", err)
}
ctx := s.reqCtx(req)
// Get last import time from metadata
lastImportStr, err := store.GetMetadata(ctx, "last_import_time")
if err != nil {
// No metadata yet - first run, skip check
return nil
}
lastImportTime, err := time.Parse(time.RFC3339, lastImportStr)
if err != nil {
// Invalid timestamp - skip check
return nil
}
// Find JSONL file path
jsonlPath := s.findJSONLPath(req)
if jsonlPath == "" {
// No JSONL file found
return nil
}
// Check JSONL mtime
stat, err := os.Stat(jsonlPath)
if err != nil {
// JSONL doesn't exist or can't be read
return nil
}
// Compare: if JSONL is newer, it's stale
if stat.ModTime().After(lastImportTime) {
// JSONL is newer! Trigger auto-import
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: daemon detected stale JSONL (modified %v, last import %v), auto-importing...\n",
stat.ModTime(), lastImportTime)
}
// TODO: Trigger actual import - for now just log
// This requires refactoring autoImportIfNewer() to be callable from daemon
fmt.Fprintf(os.Stderr, "Notice: JSONL updated externally (e.g., git pull), restart daemon or run 'bd sync' for fresh data\n")
}
return nil
}
// findJSONLPath finds the JSONL file path for the request's repository
func (s *Server) findJSONLPath(req *Request) string {
// Extract repo root from request's working directory
// For now, use a simple heuristic: look for .beads/ in request's cwd
beadsDir := filepath.Join(req.Cwd, ".beads")
// Try canonical filenames in order
candidates := []string{
filepath.Join(beadsDir, "beads.jsonl"),
filepath.Join(beadsDir, "issues.jsonl"),
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}