Files
kandev/apps/backend/internal/analytics/repository/sqlite/stats.go
T
João Salavisa 0df2bbac0a feat(plugins): typed capability-gated Host data API (ADR 0043) (#1753)
* docs(plugins): draft Host data API proto (read + write DTOs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): ADR + spec + plan for the Host data API

Plugins access kandev data via capability-gated Host gRPC RPCs returning
hand-mapped DTOs, never the DB directly. ADR 0042.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): add Host data API RPCs and DTOs to plugin proto

Extends the existing service Host (per ADR 0042) with read RPCs
(ListTasks/GetTask, ListWorkspaces, ListWorkflows, ListWorkflowSteps,
ListAgentProfiles, ListRepositories, ListSessions, ListSessionCodeStats)
and declares the deferred phase-2 write RPCs (CreateTask, UpdateTask,
CreateComment) so the contract is stable for later tasks. Regenerates
plugin.pb.go/plugin_grpc.pb.go via `make -C apps/backend proto`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugins): Host data API SDK accessors + session LOC aggregation

Wave 1-2 of ADR 0042: pkg/pluginsdk gains typed reader accessors (host.Tasks(),
host.Sessions().CodeStats(), host.Workflows(), ...) over the extended Host gRPC
service, with proto<->native DTO conversions and an UnimplementedHostData seam
for the kandev-side impl. internal/analytics gains the per-session LOC
aggregation those readers surface. Backend handlers + capability gating land in
the next wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): capability-gated Host data read handlers (ADR 0042)

Implement pluginHost Tasks/Sessions/Workspaces/Workflows/AgentProfiles/
Repositories readers: each gated on api_read:<resource> (PermissionDenied
otherwise), backed by the service layer via narrow injected interfaces, and
hand-mapped to the pluginsdk DTOs. Sessions().CodeStats routes through the
analytics aggregation; acp_session_id preserves the metadata->resume_token
fallback. Wired through plugins.Service.SetDataSources + backendapp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): wire test for Host data reads + docs (ADR 0042 accepted)

End-to-end test over the real go-plugin transport: session data round-trips,
per-resource api_read gating returns PermissionDenied over the wire, cursor
pagination. Document the Host data API + SDK accessors in GRPC-CONTRACT.md,
reconcile the spec to shipped code, mark ADR 0042 accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): full session history + snapshot profile name in data API

Sessions().List now fetches tasks with includeArchived=true — an archived
task's sessions are still real sessions, and CodeStats already made the
same call at the SQL layer, so the two session reads were inconsistent
(26 of 359 sessions visible on a real instance). Tasks().List keeps
archived tasks excluded.

Session gains agent_profile_name (proto field 10) sourced from the
profile snapshot, so sessions of since-uninstalled agents keep a human
label — the AgentProfiles registry only lists live agents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): renumber Host data API ADR 0042 -> 0043

main merged its own ADR 0042 (project shell output); renumber this one to the
next free slot and update all references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address Host data API review (perf, pagination, filters, proto)

- paginate before DTO conversion so per-session ACP lookups run <=limit times
- fetch all source pages (no silent 1000/workspace truncation); accurate HasMore
- stable id tiebreaker on paginated sorts
- AND SessionFilter.TaskIDs with WorkspaceIDs; exclude config-mode sessions from
  CodeStats (shared dialect.ExcludeConfigModePredicate) so List/CodeStats match
- Get returns codes.NotFound consistently in-process and over the wire
- wrap bare-DTO RPC responses (GetTaskResponse etc.) for Buf response uniqueness

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): mark Host data API plan tasks done

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:43:58 +01:00

748 lines
27 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/jmoiron/sqlx"
"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
}
// defaultSessionCodeStatsLimit bounds ListSessionCodeStats when the caller
// does not specify one, mirroring GetTaskStats' default page size.
const defaultSessionCodeStatsLimit = 500
// ListSessionCodeStats returns, per session, committed LOC (summed from
// task_session_commits) and PEAK pending-diff LOC (the largest single
// task_session_git_snapshots snapshot, not the latest — the latest snapshot
// is usually a clean tree after a commit, merge, or archive). This is the
// per-session line-of-code aggregation the kandev-plugin-agent-stats plugin
// used to compute by reading the SQLite file directly (see ADR 0043); the
// SQL here mirrors that plugin's sessionsQuery.
//
// Portability: the committed-sum half of this query is plain SQL and works
// unchanged on both drivers. The peak-pending half must walk each snapshot's
// `files` JSON object (keyed by file path, see task/models.GitSnapshot.Files)
// to sum per-file additions/deletions — SQLite does this with json_each,
// Postgres with jsonb_each on the same object; peakPendingSnapshotSubquery
// branches on driver to build the equivalent fragment for each. Both paths
// are covered by SQLite unit tests here; the Postgres path is exercised by
// the ADR 0027-style env-gated Postgres suite (KANDEV_TEST_POSTGRES_DSN) at
// the task/repository layer for the underlying schema, not re-verified here
// against a live Postgres instance — if jsonb_each ever proves not to match
// SQLite's json_each semantics for this shape, guard the Postgres branch
// with a clear error instead of returning silently-wrong pending numbers.
func (r *Repository) ListSessionCodeStats(
ctx context.Context,
filter models.SessionCodeStatsFilter,
) ([]*models.SessionCodeStats, error) {
driver := r.ro.DriverName()
where, args := buildSessionCodeStatsFilter(filter)
// Exclude office config-mode tasks' sessions (internal bookkeeping, not
// plugin-visible work items) — the same exclusion Sessions().List applies
// via fetchTasksForWorkspaces' excludeConfig=true, so the Host data API's
// List and CodeStats reads cover the same session set.
where += " AND " + dialect.ExcludeConfigModePredicate(driver, "t.metadata")
query := fmt.Sprintf(`
SELECT
ts.id AS session_id,
COALESCE(commit_stats.insertions, 0) AS lines_added_committed,
COALESCE(commit_stats.deletions, 0) AS lines_deleted_committed,
COALESCE(peak_stats.peak_additions, 0) AS lines_added_peak_pending,
COALESCE(peak_stats.peak_deletions, 0) AS lines_deleted_peak_pending
FROM task_sessions ts
JOIN tasks t ON t.id = ts.task_id
LEFT JOIN (
SELECT c.session_id,
SUM(c.insertions) AS insertions,
SUM(c.deletions) AS deletions
FROM task_session_commits c
GROUP BY c.session_id
) commit_stats ON commit_stats.session_id = ts.id
LEFT JOIN (%s) peak_stats ON peak_stats.session_id = ts.id
WHERE %s
ORDER BY ts.started_at ASC, ts.id ASC
LIMIT ? OFFSET ?
`, peakPendingSnapshotSubquery(driver), where)
inQuery, inArgs, err := sqlx.In(query, args...)
if err != nil {
return nil, err
}
inQuery = r.ro.Rebind(inQuery)
rows, err := r.ro.QueryContext(ctx, inQuery, inArgs...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return scanSessionCodeStats(rows)
}
// buildSessionCodeStatsFilter turns a SessionCodeStatsFilter into a WHERE
// clause (against the ts/t aliases used by ListSessionCodeStats) and its
// positional args, including the trailing LIMIT/OFFSET values.
func buildSessionCodeStatsFilter(filter models.SessionCodeStatsFilter) (string, []any) {
var conds []string
var args []any
if len(filter.SessionIDs) > 0 {
conds = append(conds, "ts.id IN (?)")
args = append(args, filter.SessionIDs)
}
if len(filter.TaskIDs) > 0 {
conds = append(conds, "ts.task_id IN (?)")
args = append(args, filter.TaskIDs)
}
if len(filter.WorkspaceIDs) > 0 {
conds = append(conds, "t.workspace_id IN (?)")
args = append(args, filter.WorkspaceIDs)
}
if len(filter.States) > 0 {
conds = append(conds, "ts.state IN (?)")
args = append(args, filter.States)
}
where := "1 = 1"
if len(conds) > 0 {
where = strings.Join(conds, " AND ")
}
limit := filter.Limit
if limit <= 0 {
limit = defaultSessionCodeStatsLimit
}
offset := filter.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
return where, args
}
// scanSessionCodeStats reads all rows of a ListSessionCodeStats query result.
func scanSessionCodeStats(rows *sql.Rows) ([]*models.SessionCodeStats, error) {
var results []*models.SessionCodeStats
for rows.Next() {
var stat models.SessionCodeStats
if err := rows.Scan(
&stat.SessionID,
&stat.LinesAddedCommitted, &stat.LinesDeletedCommitted,
&stat.LinesAddedPeakPending, &stat.LinesDeletedPeakPending,
); err != nil {
return nil, err
}
results = append(results, &stat)
}
return results, rows.Err()
}
// peakPendingSnapshotSubquery returns a derived-table SELECT (session_id,
// peak_additions, peak_deletions) that finds, per session, the MAX single
// git-snapshot total across task_session_git_snapshots.files. additions and
// deletions are maximized independently (matching the source plugin), so the
// reported peak-additions and peak-deletions snapshots need not be the same
// snapshot.
func peakPendingSnapshotSubquery(drv string) string {
if dialect.IsPostgres(drv) {
return `
SELECT snap.session_id,
MAX(snap.additions) AS peak_additions,
MAX(snap.deletions) AS peak_deletions
FROM (
SELECT g.id AS snapshot_id, g.session_id,
SUM(COALESCE((f.jvalue->>'additions')::numeric, 0)) AS additions,
SUM(COALESCE((f.jvalue->>'deletions')::numeric, 0)) AS deletions
FROM task_session_git_snapshots g,
jsonb_each(g.files::jsonb) AS f(jkey, jvalue)
GROUP BY g.id, g.session_id
) snap
GROUP BY snap.session_id`
}
return `
SELECT snap.session_id,
MAX(snap.additions) AS peak_additions,
MAX(snap.deletions) AS peak_deletions
FROM (
SELECT g.id AS snapshot_id, g.session_id,
SUM(COALESCE(json_extract(f.value, '$.additions'), 0)) AS additions,
SUM(COALESCE(json_extract(f.value, '$.deletions'), 0)) AS deletions
FROM task_session_git_snapshots g, json_each(g.files) f
GROUP BY g.id, g.session_id
) snap
GROUP BY snap.session_id`
}