Display only current project statistics in statusline. (#3)

This commit is contained in:
2025-12-18 22:36:37 +00:00
parent 2f303454af
commit ed8b5e92e1
8 changed files with 184 additions and 48 deletions
+7 -5
View File
@@ -643,7 +643,8 @@ func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) {
// handleGetStats returns worker statistics.
func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
retrievalStats := s.GetRetrievalStats()
project := r.URL.Query().Get("project")
retrievalStats := s.GetRetrievalStats(project)
sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context())
response := map[string]interface{}{
@@ -658,7 +659,7 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
}
// Include project-specific observation count if project is specified
if project := r.URL.Query().Get("project"); project != "" {
if project != "" {
count, err := s.observationStore.GetObservationCount(r.Context(), project)
if err == nil {
response["projectObservations"] = count
@@ -671,7 +672,8 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) {
// handleGetRetrievalStats returns detailed retrieval statistics.
func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) {
stats := s.GetRetrievalStats()
project := r.URL.Query().Get("project")
stats := s.GetRetrievalStats(project)
writeJSON(w, stats)
}
@@ -775,7 +777,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
clusteredObservations := clusterObservations(freshObservations, 0.4)
// Record retrieval stats (no verification done, so verified=0, deleted=0)
s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, true)
s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, true)
log.Info().
Str("project", project).
@@ -853,7 +855,7 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
duplicatesRemoved := len(freshObservations) - len(clusteredObservations)
// Record retrieval stats (no verification done)
s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, false)
s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, false)
log.Info().
Str("project", project).
+102 -15
View File
@@ -65,6 +65,7 @@ func testService(t *testing.T) (*Service, func()) {
ctx: ctx,
cancel: cancel,
startTime: time.Now(),
retrievalStats: make(map[string]*RetrievalStats),
}
svc.setupRoutes()
@@ -436,8 +437,8 @@ func TestRetrievalStats(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
// Check stats
stats := svc.GetRetrievalStats()
// Check stats for the specific project
stats := svc.GetRetrievalStats(project)
assert.Equal(t, int64(1), stats.TotalRequests)
assert.Equal(t, int64(1), stats.SearchRequests)
assert.GreaterOrEqual(t, stats.ObservationsServed, int64(1))
@@ -1516,21 +1517,22 @@ func TestGetRetrievalStats(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
// Initially all zeros
stats := svc.GetRetrievalStats()
project := "stats-test"
// Initially all zeros for this project
stats := svc.GetRetrievalStats(project)
assert.Equal(t, int64(0), stats.TotalRequests)
assert.Equal(t, int64(0), stats.SearchRequests)
// Make some requests to increment stats
project := "stats-test"
createTestObservation(t, svc.observationStore, project, "Test", "Content", []string{"test"})
req := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+project+"&query=test", nil)
rec := httptest.NewRecorder()
svc.router.ServeHTTP(rec, req)
// Stats should be updated
stats = svc.GetRetrievalStats()
// Stats should be updated for this project
stats = svc.GetRetrievalStats(project)
assert.GreaterOrEqual(t, stats.TotalRequests, int64(1))
}
@@ -1786,6 +1788,81 @@ func TestHandleGetStats_AllProjects(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code)
}
// TestStatuslineRetrievalStatsAreProjectSpecific verifies that the stats endpoint
// (used by statusline hook) returns project-specific retrieval statistics.
// This is critical for correct statusline display in multi-project environments.
func TestStatuslineRetrievalStatsAreProjectSpecific(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
projectA := "statusline-project-a"
projectB := "statusline-project-b"
// Create observations in both projects
createTestObservation(t, svc.observationStore, projectA, "Observation A", "Content for project A", []string{"test"})
createTestObservation(t, svc.observationStore, projectB, "Observation B", "Content for project B", []string{"test"})
// Make search requests for projectA (simulates user activity in that project)
reqA := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+projectA+"&query=test", nil)
recA := httptest.NewRecorder()
svc.router.ServeHTTP(recA, reqA)
assert.Equal(t, http.StatusOK, recA.Code)
// Make multiple search requests for projectB
for i := 0; i < 3; i++ {
reqB := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+projectB+"&query=test", nil)
recB := httptest.NewRecorder()
svc.router.ServeHTTP(recB, reqB)
assert.Equal(t, http.StatusOK, recB.Code)
}
// Now verify stats endpoint returns project-specific stats (as statusline hook does)
// Check projectA stats
reqStatsA := httptest.NewRequest(http.MethodGet, "/api/stats?project="+projectA, nil)
recStatsA := httptest.NewRecorder()
svc.router.ServeHTTP(recStatsA, reqStatsA)
assert.Equal(t, http.StatusOK, recStatsA.Code)
var responseA map[string]interface{}
err := json.Unmarshal(recStatsA.Body.Bytes(), &responseA)
require.NoError(t, err)
// Verify projectA has 1 search request
retrievalA := responseA["retrieval"].(map[string]interface{})
assert.Equal(t, float64(1), retrievalA["SearchRequests"], "projectA should have 1 search request")
assert.Equal(t, float64(1), retrievalA["TotalRequests"], "projectA should have 1 total request")
// Check projectB stats
reqStatsB := httptest.NewRequest(http.MethodGet, "/api/stats?project="+projectB, nil)
recStatsB := httptest.NewRecorder()
svc.router.ServeHTTP(recStatsB, reqStatsB)
assert.Equal(t, http.StatusOK, recStatsB.Code)
var responseB map[string]interface{}
err = json.Unmarshal(recStatsB.Body.Bytes(), &responseB)
require.NoError(t, err)
// Verify projectB has 3 search requests
retrievalB := responseB["retrieval"].(map[string]interface{})
assert.Equal(t, float64(3), retrievalB["SearchRequests"], "projectB should have 3 search requests")
assert.Equal(t, float64(3), retrievalB["TotalRequests"], "projectB should have 3 total requests")
// Check aggregate stats (no project filter)
reqStatsAll := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
recStatsAll := httptest.NewRecorder()
svc.router.ServeHTTP(recStatsAll, reqStatsAll)
assert.Equal(t, http.StatusOK, recStatsAll.Code)
var responseAll map[string]interface{}
err = json.Unmarshal(recStatsAll.Body.Bytes(), &responseAll)
require.NoError(t, err)
// Verify aggregate has sum of all requests (1 + 3 = 4)
retrievalAll := responseAll["retrieval"].(map[string]interface{})
assert.Equal(t, float64(4), retrievalAll["SearchRequests"], "aggregate should have 4 search requests")
assert.Equal(t, float64(4), retrievalAll["TotalRequests"], "aggregate should have 4 total requests")
}
// TestHandleSubagentComplete_WithSession tests subagent completion with existing session.
func TestHandleSubagentComplete_WithSession(t *testing.T) {
svc, cleanup := testService(t)
@@ -2249,25 +2326,35 @@ func TestRecordRetrievalStats(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
// Initially all zeros
stats := svc.GetRetrievalStats()
project := "test-project"
// Initially all zeros for this project
stats := svc.GetRetrievalStats(project)
assert.Equal(t, int64(0), stats.TotalRequests)
// Record a search request
svc.recordRetrievalStats(10, 1, 0, true)
stats = svc.GetRetrievalStats()
// Record a search request for the project
svc.recordRetrievalStats(project, 10, 1, 0, true)
stats = svc.GetRetrievalStats(project)
assert.Equal(t, int64(1), stats.TotalRequests)
assert.Equal(t, int64(10), stats.ObservationsServed)
assert.Equal(t, int64(1), stats.VerifiedStale)
assert.Equal(t, int64(1), stats.SearchRequests)
// Record a context injection
svc.recordRetrievalStats(5, 0, 1, false)
stats = svc.GetRetrievalStats()
// Record a context injection for the project
svc.recordRetrievalStats(project, 5, 0, 1, false)
stats = svc.GetRetrievalStats(project)
assert.Equal(t, int64(2), stats.TotalRequests)
assert.Equal(t, int64(15), stats.ObservationsServed)
assert.Equal(t, int64(1), stats.DeletedInvalid)
assert.Equal(t, int64(1), stats.ContextInjections)
// Different project should have zero stats
otherStats := svc.GetRetrievalStats("other-project")
assert.Equal(t, int64(0), otherStats.TotalRequests)
// Aggregate stats (empty project) should show all
aggStats := svc.GetRetrievalStats("")
assert.Equal(t, int64(2), aggStats.TotalRequests)
}
// TestHandleSelfCheck_WithInitError tests self-check with init error.
+52 -19
View File
@@ -80,8 +80,9 @@ type Service struct {
server *http.Server
startTime time.Time
// Retrieval statistics
retrievalStats RetrievalStats
// Retrieval statistics (per-project)
retrievalStats map[string]*RetrievalStats
retrievalStatsMu sync.RWMutex
// Lifecycle
ctx context.Context
@@ -137,6 +138,7 @@ func NewService(version string) (*Service, error) {
cancel: cancel,
startTime: time.Now(),
updater: update.New(version, installDir),
retrievalStats: make(map[string]*RetrievalStats),
}
// Setup middleware and routes (health endpoint works immediately)
@@ -673,29 +675,60 @@ func (s *Service) setupRoutes() {
})
}
// recordRetrievalStats atomically updates retrieval statistics.
func (s *Service) recordRetrievalStats(served, verified, deleted int64, isSearch bool) {
atomic.AddInt64(&s.retrievalStats.TotalRequests, 1)
atomic.AddInt64(&s.retrievalStats.ObservationsServed, served)
atomic.AddInt64(&s.retrievalStats.VerifiedStale, verified)
atomic.AddInt64(&s.retrievalStats.DeletedInvalid, deleted)
// recordRetrievalStats atomically updates retrieval statistics for a project.
func (s *Service) recordRetrievalStats(project string, served, verified, deleted int64, isSearch bool) {
s.retrievalStatsMu.Lock()
stats := s.retrievalStats[project]
if stats == nil {
stats = &RetrievalStats{}
s.retrievalStats[project] = stats
}
s.retrievalStatsMu.Unlock()
atomic.AddInt64(&stats.TotalRequests, 1)
atomic.AddInt64(&stats.ObservationsServed, served)
atomic.AddInt64(&stats.VerifiedStale, verified)
atomic.AddInt64(&stats.DeletedInvalid, deleted)
if isSearch {
atomic.AddInt64(&s.retrievalStats.SearchRequests, 1)
atomic.AddInt64(&stats.SearchRequests, 1)
} else {
atomic.AddInt64(&s.retrievalStats.ContextInjections, 1)
atomic.AddInt64(&stats.ContextInjections, 1)
}
}
// GetRetrievalStats returns a copy of the retrieval stats.
func (s *Service) GetRetrievalStats() RetrievalStats {
return RetrievalStats{
TotalRequests: atomic.LoadInt64(&s.retrievalStats.TotalRequests),
ObservationsServed: atomic.LoadInt64(&s.retrievalStats.ObservationsServed),
VerifiedStale: atomic.LoadInt64(&s.retrievalStats.VerifiedStale),
DeletedInvalid: atomic.LoadInt64(&s.retrievalStats.DeletedInvalid),
SearchRequests: atomic.LoadInt64(&s.retrievalStats.SearchRequests),
ContextInjections: atomic.LoadInt64(&s.retrievalStats.ContextInjections),
// GetRetrievalStats returns a copy of the retrieval stats for a project.
// If project is empty, returns aggregate stats across all projects.
func (s *Service) GetRetrievalStats(project string) RetrievalStats {
s.retrievalStatsMu.RLock()
defer s.retrievalStatsMu.RUnlock()
if project != "" {
// Return stats for specific project
stats := s.retrievalStats[project]
if stats == nil {
return RetrievalStats{}
}
return RetrievalStats{
TotalRequests: atomic.LoadInt64(&stats.TotalRequests),
ObservationsServed: atomic.LoadInt64(&stats.ObservationsServed),
VerifiedStale: atomic.LoadInt64(&stats.VerifiedStale),
DeletedInvalid: atomic.LoadInt64(&stats.DeletedInvalid),
SearchRequests: atomic.LoadInt64(&stats.SearchRequests),
ContextInjections: atomic.LoadInt64(&stats.ContextInjections),
}
}
// Aggregate stats across all projects
var result RetrievalStats
for _, stats := range s.retrievalStats {
result.TotalRequests += atomic.LoadInt64(&stats.TotalRequests)
result.ObservationsServed += atomic.LoadInt64(&stats.ObservationsServed)
result.VerifiedStale += atomic.LoadInt64(&stats.VerifiedStale)
result.DeletedInvalid += atomic.LoadInt64(&stats.DeletedInvalid)
result.SearchRequests += atomic.LoadInt64(&stats.SearchRequests)
result.ContextInjections += atomic.LoadInt64(&stats.ContextInjections)
}
return result
}
// Start starts the worker service.