Files
kandev/apps/backend/internal/office/repository/sqlite/dashboard.go
T
2026-05-16 20:07:57 +01:00

286 lines
9.6 KiB
Go

package sqlite
import (
"context"
"strings"
"time"
"github.com/kandev/kandev/internal/office/models"
)
// RunActivityRow is a raw row from the run-activity aggregate query.
type RunActivityRow struct {
Date string `db:"date"`
Succeeded int `db:"succeeded"`
Failed int `db:"failed"`
Other int `db:"other"`
}
// QueryRunActivity returns per-day run outcome counts for the last n days,
// joining task_sessions through tasks to scope by workspace.
func (r *Repository) QueryRunActivity(ctx context.Context, workspaceID string, days int) ([]RunActivityRow, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
var rows []RunActivityRow
err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(`
SELECT DATE(ts.started_at) as date,
SUM(CASE WHEN ts.state = 'COMPLETED' THEN 1 ELSE 0 END) as succeeded,
SUM(CASE WHEN ts.state = 'FAILED' THEN 1 ELSE 0 END) as failed,
COUNT(*) - SUM(CASE WHEN ts.state IN ('COMPLETED','FAILED') THEN 1 ELSE 0 END) as other
FROM task_sessions ts
WHERE ts.started_at >= ?
AND ts.task_id IN (
SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0
)
GROUP BY DATE(ts.started_at)
ORDER BY date
`), cutoff, workspaceID)
if err != nil {
return nil, err
}
return rows, nil
}
// TaskBreakdownRow is a raw row from the task-breakdown aggregate query.
type TaskBreakdownRow struct {
State string `db:"state"`
Count int `db:"count"`
}
// QueryTaskBreakdown returns task counts grouped by state for a workspace.
func (r *Repository) QueryTaskBreakdown(ctx context.Context, workspaceID string) ([]TaskBreakdownRow, error) {
var rows []TaskBreakdownRow
err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(`
SELECT COALESCE(state,'') as state, COUNT(*) as count
FROM tasks
WHERE workspace_id = ?
AND is_ephemeral = 0
AND archived_at IS NULL
GROUP BY state
`), workspaceID)
if err != nil {
return nil, err
}
return rows, nil
}
// RecentTaskRow is a raw row from the recent-tasks query.
type RecentTaskRow struct {
ID string `db:"id"`
Identifier string `db:"identifier"`
Title string `db:"title"`
State string `db:"state"`
AssigneeAgentProfileID string `db:"assignee"`
UpdatedAt string `db:"updated_at"`
}
// QueryRecentTasks returns the n most recently updated non-archived tasks for a workspace.
func (r *Repository) QueryRecentTasks(ctx context.Context, workspaceID string, limit int) ([]RecentTaskRow, error) {
var rows []RecentTaskRow
err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(`
SELECT id,
COALESCE(identifier,'') as identifier,
title,
COALESCE(state,'') as state,
`+RunnerProjection("tasks")+` as assignee,
updated_at
FROM tasks
WHERE workspace_id = ?
AND is_ephemeral = 0
AND archived_at IS NULL
ORDER BY updated_at DESC
LIMIT ?
`), workspaceID, limit)
if err != nil {
return nil, err
}
return rows, nil
}
// LiveSessionRow is a raw row from the recent-sessions query for live runs.
type LiveSessionRow struct {
ID string `db:"id"`
TaskID string `db:"task_id"`
AgentExecID string `db:"agent_execution_id"`
State string `db:"state"`
StartedAt string `db:"started_at"`
CompletedAt *string `db:"completed_at"`
}
// QueryRecentSessions returns the n most recent task sessions for a workspace,
// joining through tasks for workspace scoping.
func (r *Repository) QueryRecentSessions(ctx context.Context, workspaceID string, limit int) ([]LiveSessionRow, error) {
var rows []LiveSessionRow
err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(`
SELECT ts.id,
ts.task_id,
COALESCE(ts.agent_execution_id,'') as agent_execution_id,
COALESCE(ts.state,'') as state,
ts.started_at,
ts.completed_at
FROM task_sessions ts
WHERE ts.task_id IN (
SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0
)
ORDER BY ts.started_at DESC
LIMIT ?
`), workspaceID, limit)
if err != nil {
return nil, err
}
return rows, nil
}
// TaskTitleRow holds the minimal task fields needed for live-runs enrichment.
type TaskTitleRow struct {
ID string `db:"id"`
Title string `db:"title"`
Identifier string `db:"identifier"`
}
// GetTasksByIDs returns minimal task info for a set of task IDs.
// IDs not found in the database are silently omitted from the result.
func (r *Repository) GetTasksByIDs(ctx context.Context, ids []string) ([]TaskTitleRow, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `SELECT id,
COALESCE(title,'') as title,
COALESCE(identifier,'') as identifier
FROM tasks
WHERE id IN (` + strings.Join(placeholders, ",") + `)`
var rows []TaskTitleRow
if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(query), args...); err != nil {
return nil, err
}
return rows, nil
}
// AgentSessionRow is a raw row from the per-agent recent-sessions query.
// Mirrors LiveSessionRow but adds the agent_profile_id column so callers
// can group rows by agent without re-querying.
type AgentSessionRow struct {
ID string `db:"id"`
TaskID string `db:"task_id"`
AgentProfileID string `db:"agent_profile_id"`
State string `db:"state"`
StartedAt string `db:"started_at"`
CompletedAt *string `db:"completed_at"`
// UpdatedAt is the moment the row's state last changed. For office
// IDLE sessions (which never set completed_at), this is the
// stable "turn ended" timestamp the dashboard duration math uses.
UpdatedAt string `db:"updated_at"`
}
// ListRecentSessionsByAgentBatch returns up to perAgentLimit most-recent
// task_sessions rows for each of the supplied agent_profile_ids, in a single
// query. Result map keys are agent_profile_id; rows within each slice are
// ordered started_at DESC. Empty input returns an empty map.
//
// Uses the SQLite window function ROW_NUMBER() (requires SQLite ≥ 3.25,
// which mattn/go-sqlite3 v1.14.x bundles). This replaces the per-agent loop
// in dashboard.collectAgentSessions to keep GetAgentSummaries query count
// constant regardless of agent count.
func (r *Repository) ListRecentSessionsByAgentBatch(
ctx context.Context, agentInstanceIDs []string, perAgentLimit int,
) (map[string][]AgentSessionRow, error) {
out := make(map[string][]AgentSessionRow, len(agentInstanceIDs))
if len(agentInstanceIDs) == 0 {
return out, nil
}
if perAgentLimit <= 0 {
perAgentLimit = 5
}
placeholders := make([]string, len(agentInstanceIDs))
args := make([]interface{}, 0, len(agentInstanceIDs)+1)
for i, id := range agentInstanceIDs {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, perAgentLimit)
query := `SELECT id, task_id, agent_profile_id, state, started_at, completed_at, updated_at
FROM (
SELECT ts.id,
ts.task_id,
COALESCE(ts.agent_profile_id, '') as agent_profile_id,
COALESCE(ts.state, '') as state,
ts.started_at,
ts.completed_at,
ts.updated_at,
ROW_NUMBER() OVER (
PARTITION BY ts.agent_profile_id
ORDER BY ts.started_at DESC
) AS rn
FROM task_sessions ts
WHERE ts.agent_profile_id IN (` + strings.Join(placeholders, ",") + `)
) sub
WHERE rn <= ?
ORDER BY agent_profile_id, started_at DESC`
var rows []AgentSessionRow
if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(query), args...); err != nil {
return nil, err
}
for _, row := range rows {
out[row.AgentProfileID] = append(out[row.AgentProfileID], row)
}
return out, nil
}
// SessionCommandCountRow holds a session_id → tool_call count pair.
type SessionCommandCountRow struct {
SessionID string `db:"session_id"`
Count int `db:"count"`
}
// CountToolCallMessagesBySession returns the number of tool_call messages for
// each session in `sessionIDs`. Sessions with zero tool calls are omitted from
// the result map. The query reads from task_session_messages.
func (r *Repository) CountToolCallMessagesBySession(ctx context.Context, sessionIDs []string) (map[string]int, error) {
if len(sessionIDs) == 0 {
return map[string]int{}, nil
}
placeholders := make([]string, len(sessionIDs))
args := make([]interface{}, 0, len(sessionIDs)+1)
for i, id := range sessionIDs {
placeholders[i] = "?"
args = append(args, id)
}
query := `SELECT task_session_id as session_id, COUNT(*) as count
FROM task_session_messages
WHERE type = 'tool_call'
AND task_session_id IN (` + strings.Join(placeholders, ",") + `)
GROUP BY task_session_id`
var rows []SessionCommandCountRow
if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(query), args...); err != nil {
return nil, err
}
out := make(map[string]int, len(rows))
for _, row := range rows {
out[row.SessionID] = row.Count
}
return out, nil
}
// BucketTaskBreakdown converts raw state-count rows into a TaskBreakdown.
func BucketTaskBreakdown(rows []TaskBreakdownRow) models.TaskBreakdown {
var bd models.TaskBreakdown
for _, row := range rows {
switch row.State {
case "COMPLETED":
bd.Done += row.Count
case "IN_PROGRESS", "SCHEDULING":
bd.InProgress += row.Count
case "BLOCKED":
bd.Blocked += row.Count
default:
bd.Open += row.Count
}
}
return bd
}