diff --git a/apps/backend/internal/analytics/dto/dto.go b/apps/backend/internal/analytics/dto/dto.go index c08d516d7e..ad7a83a817 100644 --- a/apps/backend/internal/analytics/dto/dto.go +++ b/apps/backend/internal/analytics/dto/dto.go @@ -13,6 +13,8 @@ type TaskStatsDTO struct { UserMessageCount int `json:"user_message_count"` ToolCallCount int `json:"tool_call_count"` TotalDurationMs int64 `json:"total_duration_ms"` + ActiveDurationMs int64 `json:"active_duration_ms"` + ElapsedSpanMs int64 `json:"elapsed_span_ms"` CreatedAt string `json:"created_at"` CompletedAt *string `json:"completed_at,omitempty"` } diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index 8dc9af868c..7ff74e7ec1 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -154,6 +154,8 @@ func taskStatsToDTOs(taskStats []*models.TaskStats) []dto.TaskStatsDTO { UserMessageCount: ts.UserMessageCount, ToolCallCount: ts.ToolCallCount, TotalDurationMs: ts.TotalDurationMs, + ActiveDurationMs: ts.ActiveDurationMs, + ElapsedSpanMs: ts.ElapsedSpanMs, CreatedAt: ts.CreatedAt.UTC().Format(time.RFC3339), } if ts.CompletedAt != nil { diff --git a/apps/backend/internal/analytics/models/models.go b/apps/backend/internal/analytics/models/models.go index 65cb13a96f..2f789a40c8 100644 --- a/apps/backend/internal/analytics/models/models.go +++ b/apps/backend/internal/analytics/models/models.go @@ -15,6 +15,8 @@ type TaskStats struct { UserMessageCount int `json:"user_message_count"` ToolCallCount int `json:"tool_call_count"` TotalDurationMs int64 `json:"total_duration_ms"` + ActiveDurationMs int64 `json:"active_duration_ms"` + ElapsedSpanMs int64 `json:"elapsed_span_ms"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` } diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index 61d828955f..be56967ec4 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -2,6 +2,7 @@ package sqlite import ( "context" + "math" "path/filepath" "testing" "time" @@ -132,6 +133,15 @@ func execOrFatal(t *testing.T, dbConn *sqlx.DB, query string, args ...any) { } } +func assertDurationAlmostEqual(t *testing.T, got, want int64, label string) { + t.Helper() + const toleranceMs = int64(2) + diff := int64(math.Abs(float64(got - want))) + if diff > toleranceMs { + t.Errorf("expected %s %dms (+/-%dms), got %dms", label, want, toleranceMs, got) + } +} + func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) { dbConn := createTestDB(t) @@ -372,6 +382,44 @@ func TestGetTaskStats_ExcludesEphemeralTasks(t *testing.T) { } } +func TestGetTaskStats_IncludesActiveAndElapsedDurations(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'Task 1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + // Two 10-minute turns with a 60-minute idle gap in between. + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-1', 'sess-1', 'task-1', '2026-01-01T10:00:00Z', '2026-01-01T10:10:00Z', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-2', 'sess-1', 'task-1', '2026-01-01T11:10:00Z', '2026-01-01T11:20:00Z', ?, ?)`, nowStr, nowStr) + + results, err := repo.GetTaskStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetTaskStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 task stat, got %d", len(results)) + } + + // Active duration = 20m, elapsed span = 80m. + const ( + wantActiveMs = int64(20 * 60 * 1000) + wantElapsedMs = int64(80 * 60 * 1000) + ) + assertDurationAlmostEqual(t, results[0].ActiveDurationMs, wantActiveMs, "active duration") + assertDurationAlmostEqual(t, results[0].ElapsedSpanMs, wantElapsedMs, "elapsed span") + assertDurationAlmostEqual(t, results[0].TotalDurationMs, wantActiveMs, "total duration") +} + func TestGetDailyActivity_ExcludesEphemeralTasks(t *testing.T) { dbConn := createTestDB(t) repo, err := NewWithDB(dbConn, dbConn) diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 348f5be2e7..d55680af6c 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -51,7 +51,9 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start COALESCE(session_stats.message_count, 0) as message_count, COALESCE(session_stats.user_message_count, 0) as user_message_count, COALESCE(session_stats.tool_call_count, 0) as tool_call_count, - COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms, + COALESCE(turn_stats.active_duration_ms, 0) as total_duration_ms, + COALESCE(turn_stats.active_duration_ms, 0) as active_duration_ms, + COALESCE(turn_stats.elapsed_span_ms, 0) as elapsed_span_ms, t.created_at, session_stats.last_completed_at FROM tasks t LEFT JOIN ( @@ -70,7 +72,8 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start ) session_stats ON session_stats.task_id = t.id LEFT JOIN ( SELECT s.task_id, - SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as total_duration_ms + SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as active_duration_ms, + %s as elapsed_span_ms FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id WHERE (? IS NULL OR s.started_at >= ?) @@ -78,7 +81,11 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start ) turn_stats ON turn_stats.task_id = t.id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) ORDER BY t.updated_at DESC - `, dur) + `, dur, dialect.DurationMs( + drv, + "MAX(CASE WHEN turn.completed_at IS NOT NULL THEN turn.completed_at END)", + "MIN(CASE WHEN turn.completed_at IS NOT NULL THEN turn.started_at END)", + )) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), startArg, startArg, startArg, startArg, @@ -99,16 +106,21 @@ func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) var completedAtStr sql.NullString var createdAtStr string var totalDurationMs float64 + var activeDurationMs float64 + var elapsedSpanMs float64 err := rows.Scan( &stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, &stat.WorkflowID, &stat.State, &stat.SessionCount, &stat.TurnCount, &stat.MessageCount, &stat.UserMessageCount, &stat.ToolCallCount, &totalDurationMs, + &activeDurationMs, &elapsedSpanMs, &createdAtStr, &completedAtStr, ) if err != nil { return nil, err } stat.TotalDurationMs = int64(totalDurationMs) + stat.ActiveDurationMs = int64(activeDurationMs) + stat.ElapsedSpanMs = int64(elapsedSpanMs) stat.CreatedAt = parseTimeString(createdAtStr) if completedAtStr.Valid && completedAtStr.String != "" { parsedTime := parseTimeString(completedAtStr.String) diff --git a/apps/web/app/stats/stats-sections.tsx b/apps/web/app/stats/stats-sections.tsx index b31d376c8d..df56b6a7c0 100644 --- a/apps/web/app/stats/stats-sections.tsx +++ b/apps/web/app/stats/stats-sections.tsx @@ -223,6 +223,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) { Longest Tasks +

Ranked by elapsed span

Quickest Tasks +

Ranked by elapsed span

t.total_duration_ms > 0); + const filtered = [...tasks].filter((t) => t.active_duration_ms > 0 || t.elapsed_span_ms > 0); const sorted = sortDirection === "desc" - ? filtered.sort((a, b) => b.total_duration_ms - a.total_duration_ms) - : filtered.sort((a, b) => a.total_duration_ms - b.total_duration_ms); + ? filtered.sort((a, b) => b.elapsed_span_ms - a.elapsed_span_ms) + : filtered.sort((a, b) => a.elapsed_span_ms - b.elapsed_span_ms); const top3 = sorted.slice(0, 3); return ( @@ -454,7 +456,10 @@ function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationList
- {formatDuration(task.total_duration_ms)} +
{formatDuration(task.active_duration_ms)}
+
+ span {formatDuration(task.elapsed_span_ms)} +
))} diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 8decf7efab..3e3e6b8217 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -297,6 +297,8 @@ export type TaskStatsDTO = { user_message_count: number; tool_call_count: number; total_duration_ms: number; + active_duration_ms: number; + elapsed_span_ms: number; created_at: string; completed_at?: string; };