// Package project implements the read-only /api/v1/projects aggregation API.
// It groups sessions by working_dir and returns per-project statistics
// computed in a single SQL round-trip. The owner-scope resolution rules
// are identical to the session package: AllOwners / SpecificOwner / default.
package project
import (
"context"
"sort"
"strings"
"go.bigb.es/auxilia/culpa"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/platform/database"
)
// Project is the aggregated row returned per distinct working_dir. Hosts and
// Tools are populated by post-query deduplication of the comma-joined strings
// that SQLite's GROUP_CONCAT returns, so the JSON shape is always a clean
// sorted array.
type Project struct {
Cwd string `db:"cwd" json:"cwd"`
Sessions int64 `db:"sessions" json:"sessions"`
TurnCount int64 `db:"turn_count" json:"turn_count"`
TokensInTotal int64 `db:"tokens_in_total" json:"tokens_in_total"`
TokensOutTotal int64 `db:"tokens_out_total" json:"tokens_out_total"`
LastActive int64 `db:"last_active" json:"last_active"`
TopTool string `db:"top_tool" json:"top_tool"`
Hosts []string `db:"-" json:"hosts"`
Tools []string `db:"-" json:"tools"`
}
// ListFilter aggregates every option List supports.
type ListFilter struct {
Owner session.OwnerScope
Since *int64
Limit int
Offset int
}
// Repository is the SQL steward for the project aggregation API. It is
// stateless beyond its injected dependencies.
type Repository struct {
Database *database.Database `inject:""`
}
// Init satisfies the steward Initer contract.
func (r *Repository) Init(_ context.Context) error { return nil }
// List runs the aggregation query and returns one Project row per distinct
// non-NULL working_dir that the owner scope allows. An empty result returns a
// non-nil zero-length slice so JSON-encoding produces [] rather than null.
//
// The SQL is a single SELECT…GROUP BY query that joins sessions to turns. The
// correlated subquery for top_tool picks the tool with the highest turn count
// for the cwd, breaking ties by smallest tool name (ORDER BY tool ASC).
//
// Ordering is MAX(ended_at) DESC (most-recently-active project first).
func (r *Repository) List(ctx context.Context, f ListFilter) ([]Project, error) {
var (
sb strings.Builder
args []any
)
sb.WriteString(`
SELECT
s.working_dir AS cwd,
COUNT(DISTINCT s.owner || '|' || s.tool || '|' || s.host || '|' || s.session_id) AS sessions,
COALESCE(COUNT(t.turn_id), 0) AS turn_count,
COALESCE(SUM(t.tokens_in), 0) AS tokens_in_total,
COALESCE(SUM(t.tokens_out), 0) AS tokens_out_total,
MAX(s.ended_at) AS last_active,
GROUP_CONCAT(DISTINCT s.host) AS raw_hosts,
GROUP_CONCAT(DISTINCT s.tool) AS raw_tools,
COALESCE(
(
SELECT sub.tool
FROM (
SELECT t2.tool, COUNT(*) AS cnt
FROM sessions s2
LEFT JOIN turns t2 ON t2.owner = s2.owner AND t2.tool = s2.tool
AND t2.host = s2.host AND t2.session_id = s2.session_id
WHERE s2.working_dir = s.working_dir`)
// Owner clause in the top_tool correlated subquery mirrors outer scope.
switch {
case f.Owner.AllOwners:
// no additional clause
case f.Owner.SpecificOwner != nil:
sb.WriteString(` AND s2.owner = ?`)
args = append(args, *f.Owner.SpecificOwner)
default:
sb.WriteString(` AND s2.owner = ?`)
args = append(args, f.Owner.User)
}
sb.WriteString(`
GROUP BY t2.tool
ORDER BY cnt DESC, t2.tool ASC
LIMIT 1
) sub
), '') AS top_tool
FROM sessions s
LEFT JOIN turns t ON t.owner = s.owner AND t.tool = s.tool
AND t.host = s.host AND t.session_id = s.session_id
WHERE s.working_dir IS NOT NULL`)
// Owner clause for the outer query.
switch {
case f.Owner.AllOwners:
// no additional clause
case f.Owner.SpecificOwner != nil:
sb.WriteString(` AND s.owner = ?`)
args = append(args, *f.Owner.SpecificOwner)
default:
sb.WriteString(` AND s.owner = ?`)
args = append(args, f.Owner.User)
}
if f.Since != nil {
sb.WriteString(` AND s.started_at >= ?`)
args = append(args, *f.Since)
}
sb.WriteString(` GROUP BY s.working_dir ORDER BY last_active DESC LIMIT ? OFFSET ?`)
args = append(args, f.Limit, f.Offset)
// Use a raw row struct so sqlx can scan raw_hosts / raw_tools as strings.
type rawRow struct {
Cwd string `db:"cwd"`
Sessions int64 `db:"sessions"`
TurnCount int64 `db:"turn_count"`
TokensInTotal int64 `db:"tokens_in_total"`
TokensOutTotal int64 `db:"tokens_out_total"`
LastActive int64 `db:"last_active"`
TopTool string `db:"top_tool"`
RawHosts string `db:"raw_hosts"`
RawTools string `db:"raw_tools"`
}
rows := make([]rawRow, 0)
if err := r.Database.DB.SelectContext(ctx, &rows, sb.String(), args...); err != nil {
return nil, culpa.WithCode(culpa.Wrap(err, "list projects"), "DB_QUERY")
}
out := make([]Project, 0, len(rows))
for _, row := range rows {
p := Project{
Cwd: row.Cwd,
Sessions: row.Sessions,
TurnCount: row.TurnCount,
TokensInTotal: row.TokensInTotal,
TokensOutTotal: row.TokensOutTotal,
LastActive: row.LastActive,
TopTool: row.TopTool,
Hosts: splitDedup(row.RawHosts),
Tools: splitDedup(row.RawTools),
}
out = append(out, p)
}
return out, nil
}
// splitDedup splits a comma-joined GROUP_CONCAT string, deduplicates the
// values, and returns a sorted slice. An empty input returns an empty (non-nil)
// slice.
func splitDedup(raw string) []string {
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; !ok {
seen[p] = struct{}{}
out = append(out, p)
}
}
sort.Strings(out)
return out
}