package sqlite import ( "context" "database/sql" "fmt" "time" "github.com/kandev/kandev/internal/analytics/models" "github.com/kandev/kandev/internal/db/dialect" ) // parseTimeString parses time strings in various SQLite formats func parseTimeString(s string) time.Time { if s == "" { return time.Time{} } // Try various common SQLite datetime formats formats := []string{ time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05.000Z", "2006-01-02 15:04:05.000", "2006-01-02T15:04:05", } for _, format := range formats { if t, err := time.Parse(format, s); err == nil { return t } } return time.Time{} } // GetTaskStats retrieves aggregated statistics for tasks in a workspace. func (r *Repository) GetTaskStats( ctx context.Context, workspaceID string, start *time.Time, limit int, ) ([]*models.TaskStats, error) { var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } if limit <= 0 { limit = 200 } drv := r.ro.DriverName() dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") query := fmt.Sprintf(` SELECT t.id, t.title, t.workspace_id, t.workflow_id, t.state, COALESCE(session_stats.session_count, 0) as session_count, COALESCE(session_stats.turn_count, 0) as turn_count, 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.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 ( SELECT s.task_id, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count, MAX(s.completed_at) as last_completed_at FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id WHERE (? IS NULL OR s.started_at >= ?) GROUP BY s.task_id ) 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 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 >= ?) GROUP BY s.task_id ) 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 LIMIT ? `, 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, workspaceID, startArg, startArg, limit, ) if err != nil { return nil, err } defer func() { _ = rows.Close() }() return r.scanTaskStats(rows) } func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) { var results []*models.TaskStats for rows.Next() { var stat models.TaskStats 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) if !parsedTime.IsZero() { stat.CompletedAt = &parsedTime } } results = append(results, &stat) } return results, rows.Err() } // Outlier bounds for the "average turn size" metric. Determined empirically // from prod data: durations under cleanTurnMinDurationMs are no-op/aborted // turns and durations at or above cleanTurnMaxDurationMs are zombie turns // whose completed_at was backfilled across an agent restart. Both classes // badly skew the mean — excluding them drops avg duration from ~1062s to // ~289s on a ~4k-turn sample. The duration filter is half-open // [cleanTurnMinDurationMs, cleanTurnMaxDurationMs). const ( cleanTurnMinDurationMs = 1000 cleanTurnMaxDurationMs = 3600000 cleanTurnMinMessages = 1 ) // GetGlobalStats retrieves workspace-wide aggregated statistics. // Implemented as a single query over 5 CTEs (tasks, sessions, turns, // clean_turn, messages). The four count/sum CTEs each scan their underlying // table at most once per request; clean_turn additionally issues one // index-only message-count subquery per qualifying turn (kept correlated to // stay portable between the SQLite and Postgres dialects). func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) { var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } drv := r.ro.DriverName() dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") query := fmt.Sprintf(` WITH task_agg AS ( SELECT COUNT(*) AS total_tasks, SUM(CASE WHEN t.archived_at IS NOT NULL OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) THEN 1 ELSE 0 END) AS completed_tasks, SUM(CASE WHEN t.state = 'IN_PROGRESS' AND t.archived_at IS NULL THEN 1 ELSE 0 END) AS in_progress_tasks FROM tasks t LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) ), session_agg AS ( SELECT COUNT(*) AS total_sessions FROM task_sessions s JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) ), turn_agg AS ( SELECT COUNT(*) AS total_turns, COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) AS total_duration_ms FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) ), clean_turn_agg AS ( SELECT AVG(dur_ms) AS avg_turn_duration_ms, AVG(msg_count) AS avg_messages_per_turn FROM ( SELECT %s AS dur_ms, (SELECT COUNT(*) FROM task_session_messages m WHERE m.turn_id = turn.id) AS msg_count FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) AND turn.completed_at IS NOT NULL ) clean WHERE dur_ms >= %d AND dur_ms < %d AND msg_count >= %d ), message_agg AS ( SELECT COUNT(*) AS total_messages, SUM(CASE WHEN msg.author_type = 'user' THEN 1 ELSE 0 END) AS total_user_messages, SUM(CASE WHEN msg.type LIKE 'tool_%%' THEN 1 ELSE 0 END) AS total_tool_calls FROM task_session_messages msg JOIN task_sessions s ON s.id = msg.task_session_id JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) ) SELECT task_agg.total_tasks, task_agg.completed_tasks, task_agg.in_progress_tasks, session_agg.total_sessions, turn_agg.total_turns, message_agg.total_messages, message_agg.total_user_messages, message_agg.total_tool_calls, turn_agg.total_duration_ms, clean_turn_agg.avg_turn_duration_ms, clean_turn_agg.avg_messages_per_turn FROM task_agg, session_agg, turn_agg, clean_turn_agg, message_agg `, dur, dur, cleanTurnMinDurationMs, cleanTurnMaxDurationMs, cleanTurnMinMessages) var stats models.GlobalStats var totalDurationMs float64 var completedTasks, inProgressTasks sql.NullInt64 var userMessages, toolCalls sql.NullInt64 var avgTurnDurationMs, avgMessagesPerTurn sql.NullFloat64 err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), workspaceID, startArg, startArg, // task_agg workspaceID, startArg, startArg, // session_agg workspaceID, startArg, startArg, // turn_agg workspaceID, startArg, startArg, // clean_turn_agg workspaceID, startArg, startArg, // message_agg ).Scan( &stats.TotalTasks, &completedTasks, &inProgressTasks, &stats.TotalSessions, &stats.TotalTurns, &stats.TotalMessages, &userMessages, &toolCalls, &totalDurationMs, &avgTurnDurationMs, &avgMessagesPerTurn, ) if err != nil { return nil, err } stats.CompletedTasks = int(completedTasks.Int64) stats.InProgressTasks = int(inProgressTasks.Int64) stats.TotalUserMessages = int(userMessages.Int64) stats.TotalToolCalls = int(toolCalls.Int64) stats.TotalDurationMs = int64(totalDurationMs) stats.AvgTurnDurationMs = int64(avgTurnDurationMs.Float64) stats.AvgMessagesPerTurn = avgMessagesPerTurn.Float64 if stats.TotalTasks > 0 { stats.AvgTurnsPerTask = float64(stats.TotalTurns) / float64(stats.TotalTasks) stats.AvgMessagesPerTask = float64(stats.TotalMessages) / float64(stats.TotalTasks) stats.AvgDurationMsPerTask = stats.TotalDurationMs / int64(stats.TotalTasks) } return &stats, nil } // GetDailyActivity retrieves daily activity statistics for the last N days func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, days int) ([]*models.DailyActivity, error) { drv := r.ro.DriverName() dateStart := dialect.DateNowMinusDays(drv, "?") datePlus := dialect.DatePlusOneDay(drv, "date") curDate := dialect.CurrentDate(drv) dateOfTurn := dialect.DateOf(drv, "turn.started_at") dateOfMsg := dialect.DateOf(drv, "msg.created_at") query := fmt.Sprintf(` WITH RECURSIVE dates(date) AS ( SELECT %s UNION ALL SELECT %s FROM dates WHERE date < %s ) SELECT d.date, COALESCE(activity.turn_count, 0) as turn_count, COALESCE(activity.message_count, 0) as message_count, COALESCE(activity.task_count, 0) as task_count FROM dates d LEFT JOIN ( SELECT %s as activity_date, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT t.id) as task_count FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id AND %s = %s WHERE t.workspace_id = ? AND t.is_ephemeral = 0 GROUP BY %s ) activity ON activity.activity_date = d.date ORDER BY d.date ASC `, dateStart, datePlus, curDate, dateOfTurn, dateOfMsg, dateOfTurn, dateOfTurn) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), days-1, workspaceID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var results []*models.DailyActivity for rows.Next() { var activity models.DailyActivity if err := rows.Scan(&activity.Date, &activity.TurnCount, &activity.MessageCount, &activity.TaskCount); err != nil { return nil, err } results = append(results, &activity) } return results, rows.Err() } // GetCompletedTaskActivity retrieves completed task counts for the last N days func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID string, days int) ([]*models.CompletedTaskActivity, error) { drv := r.ro.DriverName() dateStart := dialect.DateNowMinusDays(drv, "?") datePlus := dialect.DatePlusOneDay(drv, "date") curDate := dialect.CurrentDate(drv) dateOfCompleted := dialect.DateOf(drv, "COALESCE(ts.completed_at, t.archived_at)") query := fmt.Sprintf(` WITH RECURSIVE dates(date) AS ( SELECT %s UNION ALL SELECT %s FROM dates WHERE date < %s ) SELECT d.date, COALESCE(activity.completed_tasks, 0) as completed_tasks FROM dates d LEFT JOIN ( SELECT %s as activity_date, COUNT(DISTINCT t.id) as completed_tasks FROM tasks t LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id LEFT JOIN ( SELECT task_id, MAX(completed_at) as completed_at FROM task_sessions WHERE completed_at IS NOT NULL GROUP BY task_id ) ts ON ts.task_id = t.id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (t.archived_at IS NOT NULL OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) AND COALESCE(ts.completed_at, t.archived_at) IS NOT NULL GROUP BY %s ) activity ON activity.activity_date = d.date ORDER BY d.date ASC `, dateStart, datePlus, curDate, dateOfCompleted, dateOfCompleted) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), days-1, workspaceID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var results []*models.CompletedTaskActivity for rows.Next() { var activity models.CompletedTaskActivity if err := rows.Scan(&activity.Date, &activity.CompletedTasks); err != nil { return nil, err } results = append(results, &activity) } return results, rows.Err() } // GetRepositoryStats retrieves aggregated statistics for repositories in a workspace func (r *Repository) GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) { var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } query := buildRepositoryStatsQuery(r.ro.DriverName()) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), startArg, startArg, startArg, startArg, startArg, startArg, startArg, startArg, workspaceID, ) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var results []*models.RepositoryStats for rows.Next() { var stats models.RepositoryStats var totalDurationMs float64 err := rows.Scan( &stats.RepositoryID, &stats.RepositoryName, &stats.TotalTasks, &stats.CompletedTasks, &stats.InProgressTasks, &stats.SessionCount, &stats.TurnCount, &stats.MessageCount, &stats.UserMessageCount, &stats.ToolCallCount, &totalDurationMs, &stats.TotalCommits, &stats.TotalFilesChanged, &stats.TotalInsertions, &stats.TotalDeletions, ) if err != nil { return nil, err } stats.TotalDurationMs = int64(totalDurationMs) results = append(results, &stats) } return results, rows.Err() } func buildRepositoryStatsQuery(drv string) string { dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") return fmt.Sprintf(` SELECT r.id, r.name, COALESCE(task_stats.total_tasks, 0) as total_tasks, COALESCE(task_stats.completed_tasks, 0) as completed_tasks, COALESCE(task_stats.in_progress_tasks, 0) as in_progress_tasks, COALESCE(session_stats.session_count, 0) as session_count, COALESCE(session_stats.turn_count, 0) as turn_count, 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(duration_stats.total_duration_ms, 0) as total_duration_ms, COALESCE(git_stats.total_commits, 0) as total_commits, COALESCE(git_stats.total_files_changed, 0) as total_files_changed, COALESCE(git_stats.total_insertions, 0) as total_insertions, COALESCE(git_stats.total_deletions, 0) as total_deletions FROM repositories r LEFT JOIN ( SELECT tr.repository_id, COUNT(DISTINCT t.id) as total_tasks, COUNT(DISTINCT CASE WHEN ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) THEN t.id END) as completed_tasks, COUNT(DISTINCT CASE WHEN t.state = 'IN_PROGRESS' THEN t.id END) as in_progress_tasks FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id WHERE t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) GROUP BY tr.repository_id ) task_stats ON task_stats.repository_id = r.id LEFT JOIN ( SELECT tr.repository_id, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, COUNT(DISTINCT msg.id) as message_count, COUNT(DISTINCT CASE WHEN msg.author_type = 'user' THEN msg.id END) as user_message_count, COUNT(DISTINCT CASE WHEN msg.type LIKE 'tool_%%' THEN msg.id END) as tool_call_count FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id WHERE t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) GROUP BY tr.repository_id ) session_stats ON session_stats.repository_id = r.id LEFT JOIN ( SELECT tr.repository_id, COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id WHERE t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) GROUP BY tr.repository_id ) duration_stats ON duration_stats.repository_id = r.id LEFT JOIN ( SELECT s.repository_id, COUNT(DISTINCT c.id) as total_commits, COALESCE(SUM(c.files_changed), 0) as total_files_changed, COALESCE(SUM(c.insertions), 0) as total_insertions, COALESCE(SUM(c.deletions), 0) as total_deletions FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id WHERE t.is_ephemeral = 0 AND s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) GROUP BY s.repository_id ) git_stats ON git_stats.repository_id = r.id WHERE r.workspace_id = ? AND r.deleted_at IS NULL ORDER BY total_duration_ms DESC, total_tasks DESC, r.name ASC `, dur) } // GetAgentUsage retrieves usage statistics per agent profile func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) { var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } drv := r.ro.DriverName() dur := dialect.DurationMs(drv, "turn.completed_at", "turn.started_at") jeName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "name") jeDisplay := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "agent_display_name") jeModel := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model") jeModelName := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "model_name") jeLLM := dialect.JSONExtract(drv, "s.agent_profile_snapshot", "llm") query := fmt.Sprintf(` SELECT s.agent_profile_id, COALESCE(%s, %s, s.agent_profile_id) as agent_profile_name, COALESCE(%s, %s, %s, '') as agent_model, COUNT(DISTINCT s.id) as session_count, COUNT(DISTINCT turn.id) as turn_count, COALESCE(SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END), 0) as total_duration_ms FROM task_sessions s JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND s.agent_profile_id != '' AND (? IS NULL OR s.started_at >= ?) GROUP BY s.agent_profile_id ORDER BY session_count DESC LIMIT ? `, jeName, jeDisplay, jeModel, jeModelName, jeLLM, dur) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), workspaceID, startArg, startArg, limit) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var results []*models.AgentUsage for rows.Next() { var usage models.AgentUsage var totalDurationMs float64 err := rows.Scan( &usage.AgentProfileID, &usage.AgentProfileName, &usage.AgentModel, &usage.SessionCount, &usage.TurnCount, &totalDurationMs, ) if err != nil { return nil, err } usage.TotalDurationMs = int64(totalDurationMs) results = append(results, &usage) } return results, rows.Err() } // GetGitStats retrieves aggregated git statistics for a workspace func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) { var startArg any if start != nil { startArg = start.UTC().Format(time.RFC3339) } query := ` SELECT COUNT(DISTINCT c.id) as total_commits, COALESCE(SUM(c.files_changed), 0) as total_files_changed, COALESCE(SUM(c.insertions), 0) as total_insertions, COALESCE(SUM(c.deletions), 0) as total_deletions FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR c.committed_at >= ?) ` var stats models.GitStats err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), workspaceID, startArg, startArg).Scan( &stats.TotalCommits, &stats.TotalFilesChanged, &stats.TotalInsertions, &stats.TotalDeletions, ) if err != nil { return nil, err } return &stats, nil }