~bigbes/lethe

2285c578382ca35a2f4dbdf16ea45ccb7c108849 — Eugene Blikh a month ago 62dbaf9
project: add /api/v1/projects aggregation; sessions: add ?cwd= filter

- session.ListFilter gains Cwd *string; List appends working_dir = ?
  clause between Host and Since in fixed order (parameterised, column
  name is a literal)
- session.Handler reads ?cwd= and threads it to filter.Cwd; empty
  string treated as absent, consistent with other optional filters
- new package internal/domain/project:
  - Repository.List groups sessions by non-NULL working_dir in a single
    SQL round-trip; correlated subquery picks top_tool with tie-broken
    by tool ASC; Hosts/Tools deduped and sorted in Go after
    GROUP_CONCAT
  - Handler mounts GET /projects; resolveScope/clampLimit/clampOffset
    duplicated from session handler; ?owner= admin gating identical
- server.Server gains Projects *project.Handler inject field; mounted
  in /api/v1 Route block
- main.go and e2e test register projectRepo + projectHnd with steward
M cmd/lethe/main.go => cmd/lethe/main.go +6 -1
@@ 25,6 25,7 @@ import (

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
	"sourcecraft.dev/bigbes/lethe/internal/domain/project"
	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/platform/database"
	"sourcecraft.dev/bigbes/lethe/internal/platform/health"


@@ 96,13 97,15 @@ func run() int {
		ingestHnd    = &ingest.Handler{}
		sessionRepo  = &session.Repository{}
		sessionHnd   = &session.Handler{}
		projectRepo  = &project.Repository{}
		projectHnd   = &project.Handler{}
		serverSvc    = &server.Server{}
	)

	registered := []any{
		loggerSvc, metricsSvc, dbSvc, dbCheckSvc, healthSetSvc,
		authSvc, ingestRepo, ingestSvc, ingestHnd,
		sessionRepo, sessionHnd, serverSvc,
		sessionRepo, sessionHnd, projectRepo, projectHnd, serverSvc,
	}

	mgr.AddComponent(ctx,


@@ 118,6 121,8 @@ func run() int {
		steward.MustServiceAsset(ingestHnd),
		steward.MustServiceAsset(sessionRepo),
		steward.MustServiceAsset(sessionHnd),
		steward.MustServiceAsset(projectRepo),
		steward.MustServiceAsset(projectHnd),
		steward.MustServiceAsset(serverSvc, steward.Root()),
	)


M cmd/lethe/main_e2e_test.go => cmd/lethe/main_e2e_test.go +3 -0
@@ 21,6 21,7 @@ import (

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
	"sourcecraft.dev/bigbes/lethe/internal/domain/project"
	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/platform/database"
	"sourcecraft.dev/bigbes/lethe/internal/platform/health"


@@ 80,6 81,8 @@ func TestEndToEnd_MultiUserIsolation(t *testing.T) {
		steward.MustServiceAsset(&ingest.Handler{}),
		steward.MustServiceAsset(&session.Repository{}),
		steward.MustServiceAsset(&session.Handler{}),
		steward.MustServiceAsset(&project.Repository{}),
		steward.MustServiceAsset(&project.Handler{}),
		steward.MustServiceAsset(srv, steward.Root()),
	)


A internal/domain/project/handler.go => internal/domain/project/handler.go +145 -0
@@ 0,0 1,145 @@
package project

import (
	"context"
	"log/slog"
	"net/http"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"
	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// Pagination knobs mirrored from the session handler spec.
const (
	defaultLimit = 50
	maxLimit     = 200
)

// allOwnersSentinel is the ?owner=* value that an admin uses to read across
// all owners. Identical to the session handler's sentinel.
const allOwnersSentinel = "*"

// Handler is the steward-managed HTTP boundary for the project aggregation
// API. Repo is the injected SQL steward; the Handler holds no other state.
type Handler struct {
	Repo *Repository `inject:""`
}

// Init satisfies the steward Initer contract.
func (h *Handler) Init(_ context.Context) error { return nil }

// Mount registers the single read route under r. Server.Init mounts this
// inside the /api/v1 group, so the effective path is /api/v1/projects.
func (h *Handler) Mount(r chi.Router) {
	r.Get("/projects", h.List)
}

// listResponse is the JSON body returned by List.
type listResponse struct {
	Projects []Project `json:"projects"`
	Limit    int       `json:"limit"`
	Offset   int       `json:"offset"`
}

// List handles GET /projects. It resolves the owner scope (admin gating on
// ?owner=), parses optional filters, clamps pagination, and writes a
// listResponse. Errors surface through apierror.Render as RFC 7807.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
	scope, err := h.resolveScope(r)
	if err != nil {
		apierror.Render(w, r, err)
		return
	}

	q := r.URL.Query()
	filter := ListFilter{Owner: scope}

	if v := q.Get("since"); v != "" {
		n, perr := strconv.ParseInt(v, 10, 64)
		if perr != nil {
			apierror.Render(w, r, culpa.WithCode(
				culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"),
				"INVALID",
			))
			return
		}
		filter.Since = &n
	}

	filter.Limit = clampLimit(q.Get("limit"))
	filter.Offset = clampOffset(q.Get("offset"))

	rows, err := h.Repo.List(r.Context(), filter)
	if err != nil {
		apierror.Render(w, r, err)
		return
	}

	if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{
		Projects: rows,
		Limit:    filter.Limit,
		Offset:   filter.Offset,
	}); writeErr != nil {
		slog.Default().ErrorContext(r.Context(), "write projects response", scribe.Err(writeErr))
	}
}

// resolveScope reads the authenticated identity off the context and the
// optional ?owner= query parameter, then returns the appropriate
// session.OwnerScope. Non-admin requests with ?owner= set are 403.
func (h *Handler) resolveScope(r *http.Request) (session.OwnerScope, error) {
	id := auth.MustIdentity(r.Context())
	param := r.URL.Query().Get("owner")
	if param == "" {
		return session.OwnerScope{User: id.User}, nil
	}
	if !id.IsAdmin {
		return session.OwnerScope{}, culpa.WithCode(
			culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
			"FORBIDDEN",
		)
	}
	if param == allOwnersSentinel {
		return session.OwnerScope{User: id.User, AllOwners: true}, nil
	}
	owner := strings.ToLower(param)
	return session.OwnerScope{User: id.User, SpecificOwner: &owner}, nil
}

// clampLimit returns the effective limit: defaultLimit when missing,
// non-numeric, or negative; capped at maxLimit when the parsed value
// exceeds it.
func clampLimit(raw string) int {
	if raw == "" {
		return defaultLimit
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < 0 {
		return defaultLimit
	}
	if n > maxLimit {
		return maxLimit
	}
	return n
}

// clampOffset returns the effective offset: 0 when missing, non-numeric,
// or negative.
func clampOffset(raw string) int {
	if raw == "" {
		return 0
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < 0 {
		return 0
	}
	return n
}

A internal/domain/project/handler_test.go => internal/domain/project/handler_test.go +161 -0
@@ 0,0 1,161 @@
package project_test

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/lethe/internal/domain/project"
	"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)

// fakeAuthMiddleware injects a fixed Identity onto the request context.
func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := auth.WithIdentity(r.Context(), id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// newHandler wires a Handler against a fresh in-memory database.
func newHandler(t *testing.T) (*project.Handler, *project.Repository) {
	t.Helper()
	repo, _ := newRepo(t)
	h := &project.Handler{Repo: repo}
	if err := h.Init(t.Context()); err != nil {
		t.Fatalf("handler.Init: %v", err)
	}
	return h, repo
}

// mountWithIdentity builds a chi router with the fake auth middleware and
// the project handler mounted under /api/v1.
func mountWithIdentity(h *project.Handler, id auth.Identity) http.Handler {
	r := chi.NewRouter()
	r.Route("/api/v1", func(r chi.Router) {
		r.Use(fakeAuthMiddleware(id))
		h.Mount(r)
	})
	return r
}

// projectListBody is the decoded response of GET /api/v1/projects.
type projectListBody struct {
	Projects []project.Project `json:"projects"`
	Limit    int               `json:"limit"`
	Offset   int               `json:"offset"`
}

// problemBody captures the RFC 7807 fields tests assert on.
type problemBody struct {
	Status int    `json:"status"`
	Code   string `json:"code"`
}

func doList(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, projectListBody) {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/projects"+query, nil)
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)
	var body projectListBody
	if rec.Code == http.StatusOK {
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatalf("unmarshal list body: %v (body=%s)", err, rec.Body.String())
		}
	}
	return rec, body
}

func TestProjectHandler_List_OneProject(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, body := doList(t, router, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Projects) != 1 {
		t.Fatalf("expected 1 project; got %d (%#v)", len(body.Projects), body.Projects)
	}
	if body.Projects[0].Cwd != "/code/x" {
		t.Errorf("Cwd: got %q; want /code/x", body.Projects[0].Cwd)
	}
	if body.Limit != 50 || body.Offset != 0 {
		t.Errorf("defaults: limit=%d offset=%d; want 50/0", body.Limit, body.Offset)
	}
}

func TestProjectHandler_List_NonAdminOwnerParamReturns403(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false})

	for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} {
		rec, _ := doList(t, router, q)
		if rec.Code != http.StatusForbidden {
			t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String())
		}
		var p problemBody
		_ = json.Unmarshal(rec.Body.Bytes(), &p)
		if p.Code != "FORBIDDEN" {
			t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code)
		}
	}
}

func TestProjectHandler_List_BadSinceReturns400(t *testing.T) {
	h, _ := newHandler(t)
	router := mountWithIdentity(h, auth.Identity{User: "alice"})

	rec, _ := doList(t, router, "?since=not-a-number")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
	}
	var p problemBody
	_ = json.Unmarshal(rec.Body.Bytes(), &p)
	if p.Code != "INVALID" {
		t.Fatalf("expected INVALID; got %q", p.Code)
	}
}

func TestProjectHandler_List_AdminOwnerStarReturnsAllOwners(t *testing.T) {
	h, repo := newHandler(t)
	db := repo.Database.DB
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")
	router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})

	rec, body := doList(t, router, "?owner=*")
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	if len(body.Projects) != 2 {
		t.Fatalf("expected 2 projects; got %d (%#v)", len(body.Projects), body.Projects)
	}
}

func TestProjectHandler_Mount_RegistersRoute(t *testing.T) {
	h, _ := newHandler(t)
	router := chi.NewRouter()
	router.Route("/api/v1", func(r chi.Router) {
		r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"}))
		h.Mount(r)
	})

	found := false
	_ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
		if method == http.MethodGet && route == "/api/v1/projects" {
			found = true
		}
		return nil
	})
	if !found {
		t.Error("expected GET /api/v1/projects registered; not found")
	}
}

A internal/domain/project/repository.go => internal/domain/project/repository.go +192 -0
@@ 0,0 1,192 @@
// 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"`

	// rawHosts and rawTools hold the comma-joined GROUP_CONCAT output before
	// the post-query split+dedupe step.
	rawHosts string `db:"raw_hosts"`
	rawTools string `db:"raw_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
}

A internal/domain/project/repository_test.go => internal/domain/project/repository_test.go +326 -0
@@ 0,0 1,326 @@
package project_test

import (
	"context"
	"testing"
	"time"

	"github.com/jmoiron/sqlx"
	_ "modernc.org/sqlite"

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/domain/project"
	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/platform/database"
)

// newTestDatabase builds a Database steward against :memory: (one DB per
// test, isolated). Cleanup runs Destroy.
func newTestDatabase(t *testing.T) *database.Database {
	t.Helper()
	d := &database.Database{
		Cfg: config.DatabaseConfig{
			Path:        ":memory:",
			BusyTimeout: 5 * time.Second,
		},
	}
	if err := d.Init(context.Background()); err != nil {
		t.Fatalf("database.Init: %v", err)
	}
	t.Cleanup(func() { _ = d.Destroy(context.Background()) })
	return d
}

// newRepo wires a project.Repository against a fresh in-memory database.
func newRepo(t *testing.T) (*project.Repository, *sqlx.DB) {
	t.Helper()
	d := newTestDatabase(t)
	repo := &project.Repository{Database: d}
	if err := repo.Init(context.Background()); err != nil {
		t.Fatalf("repo.Init: %v", err)
	}
	return repo, d.DB
}

// seedSession inserts a session row. working_dir may be empty string (treated
// as non-NULL) or use seedSessionNullCwd for a NULL working_dir row.
func seedSessionWithCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd string) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
		owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/x.jsonl",
	)
	if err != nil {
		t.Fatalf("seed session %s/%s/%s/%s (cwd=%s): %v", owner, tool, host, sid, cwd, err)
	}
}

// seedSessionNullCwd inserts a session row with a NULL working_dir.
func seedSessionNullCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
		VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`,
		owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl",
	)
	if err != nil {
		t.Fatalf("seed session null cwd %s/%s/%s/%s: %v", owner, tool, host, sid, err)
	}
}

// seedTurn inserts a turn row with the specified tool value.
func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content,
		                   model, tokens_in, tokens_out, cost_usd, tool_calls, metadata)
		VALUES (?, ?, ?, ?, ?, ?, 'user', ?, 'hello', NULL, NULL, NULL, NULL, NULL, NULL)`,
		owner, tool, host, sid, tid, seq, ts,
	)
	if err != nil {
		t.Fatalf("seed turn %s/%s: %v", sid, tid, err)
	}
}

// seedTurnFull inserts a turn row with tokens_in, tokens_out, and a model set.
func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts, tokensIn, tokensOut int64, model string) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content,
		                   model, tokens_in, tokens_out, cost_usd, tool_calls, metadata)
		VALUES (?, ?, ?, ?, ?, ?, 'assistant', ?, 'response', ?, ?, ?, NULL, NULL, NULL)`,
		owner, tool, host, sid, tid, seq, ts, model, tokensIn, tokensOut,
	)
	if err != nil {
		t.Fatalf("seed turn full %s/%s: %v", sid, tid, err)
	}
}

func TestProjectList_EmptyDB(t *testing.T) {
	repo, _ := newRepo(t)
	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if got == nil {
		t.Fatal("expected non-nil empty slice; got nil")
	}
	if len(got) != 0 {
		t.Fatalf("expected 0 projects; got %d (%#v)", len(got), got)
	}
}

func TestProjectList_OneCwdTwoSessionsThreeTurns(t *testing.T) {
	repo, db := newRepo(t)
	// Two sessions share /code/x.
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000050, "/code/x")
	// Three turns across both sessions.
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000005)
	seedTurn(t, db, "alice", "cc", "phoebe", "s2", "t2", 1, 1700000025)
	seedTurn(t, db, "alice", "cc", "phoebe", "s2", "t3", 2, 1700000035)

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1 project row; got %d (%#v)", len(got), got)
	}
	p := got[0]
	if p.Cwd != "/code/x" {
		t.Errorf("Cwd: got %q; want /code/x", p.Cwd)
	}
	if p.Sessions != 2 {
		t.Errorf("Sessions: got %d; want 2", p.Sessions)
	}
	if p.TurnCount != 3 {
		t.Errorf("TurnCount: got %d; want 3", p.TurnCount)
	}
	// LastActive = MAX(ended_at) of the two sessions = 1700000050.
	if p.LastActive != 1700000050 {
		t.Errorf("LastActive: got %d; want 1700000050", p.LastActive)
	}
}

func TestProjectList_NullCwdExcluded(t *testing.T) {
	repo, db := newRepo(t)
	// One row with a real cwd, one with NULL.
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionNullCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030)

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1 project (null cwd excluded); got %d (%#v)", len(got), got)
	}
	if got[0].Cwd != "/code/x" {
		t.Errorf("Cwd: got %q; want /code/x", got[0].Cwd)
	}
}

func TestProjectList_TopToolTiesBrokenByAsc(t *testing.T) {
	repo, db := newRepo(t)
	// Two tools each with 1 turn for the same cwd — alphabetically "cc" < "gemini".
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "alice", "gemini", "phoebe", "s2", 1700000020, 1700000030, "/code/x")
	seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000005)
	seedTurn(t, db, "alice", "gemini", "phoebe", "s2", "t2", 1, 1700000025)

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1 project; got %d", len(got))
	}
	if got[0].TopTool != "cc" {
		t.Errorf("TopTool tie: got %q; want cc (alphabetically first)", got[0].TopTool)
	}
}

func TestProjectList_HostsAndToolsDeduped(t *testing.T) {
	repo, db := newRepo(t)
	// Same tool + host across two sessions → should appear once each.
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/x")

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1 project; got %d", len(got))
	}
	p := got[0]
	if len(p.Tools) != 1 || p.Tools[0] != "cc" {
		t.Errorf("Tools: got %v; want [cc]", p.Tools)
	}
	if len(p.Hosts) != 1 || p.Hosts[0] != "phoebe" {
		t.Errorf("Hosts: got %v; want [phoebe]", p.Hosts)
	}
}

func TestProjectList_HostsAndToolsSorted(t *testing.T) {
	repo, db := newRepo(t)
	// Two different tools + two different hosts across sessions.
	seedSessionWithCwd(t, db, "alice", "gemini", "rhea", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/x")

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1 project; got %d", len(got))
	}
	p := got[0]
	// Tools sorted: cc < gemini.
	if len(p.Tools) != 2 || p.Tools[0] != "cc" || p.Tools[1] != "gemini" {
		t.Errorf("Tools sorted: got %v; want [cc gemini]", p.Tools)
	}
	// Hosts sorted: phoebe < rhea.
	if len(p.Hosts) != 2 || p.Hosts[0] != "phoebe" || p.Hosts[1] != "rhea" {
		t.Errorf("Hosts sorted: got %v; want [phoebe rhea]", p.Hosts)
	}
}

func TestProjectList_OwnerScopes(t *testing.T) {
	t.Run("AllOwners returns rows from both owners", func(t *testing.T) {
		repo, db := newRepo(t)
		seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
		seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")

		got, err := repo.List(context.Background(), project.ListFilter{
			Owner: session.OwnerScope{User: "admin", AllOwners: true},
			Limit: 50,
		})
		if err != nil {
			t.Fatalf("List: %v", err)
		}
		if len(got) != 2 {
			t.Fatalf("AllOwners: expected 2 rows; got %d (%#v)", len(got), got)
		}
	})

	t.Run("SpecificOwner pins to one owner", func(t *testing.T) {
		repo, db := newRepo(t)
		seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
		seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")

		bob := "bob"
		got, err := repo.List(context.Background(), project.ListFilter{
			Owner: session.OwnerScope{User: "admin", SpecificOwner: &bob},
			Limit: 50,
		})
		if err != nil {
			t.Fatalf("List: %v", err)
		}
		if len(got) != 1 || got[0].Cwd != "/code/y" {
			t.Fatalf("SpecificOwner bob: expected /code/y; got %#v", got)
		}
	})

	t.Run("default scope returns only caller rows", func(t *testing.T) {
		repo, db := newRepo(t)
		seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
		seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")

		got, err := repo.List(context.Background(), project.ListFilter{
			Owner: session.OwnerScope{User: "alice"},
			Limit: 50,
		})
		if err != nil {
			t.Fatalf("List: %v", err)
		}
		if len(got) != 1 || got[0].Cwd != "/code/x" {
			t.Fatalf("default scope alice: expected /code/x; got %#v", got)
		}
	})
}

func TestProjectList_TokenSums(t *testing.T) {
	repo, db := newRepo(t)
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000100, "/code/x")
	seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000010, 100, 200, "gpt-4")
	seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t2", 2, 1700000020, 50, 75, "gpt-4")

	got, err := repo.List(context.Background(), project.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("expected 1; got %d", len(got))
	}
	p := got[0]
	if p.TokensInTotal != 150 {
		t.Errorf("TokensInTotal: got %d; want 150", p.TokensInTotal)
	}
	if p.TokensOutTotal != 275 {
		t.Errorf("TokensOutTotal: got %d; want 275", p.TokensOutTotal)
	}
}

M internal/domain/session/handler.go => internal/domain/session/handler.go +3 -0
@@ 74,6 74,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
	if v := q.Get("host"); v != "" {
		filter.Host = &v
	}
	if v := q.Get("cwd"); v != "" {
		filter.Cwd = &v
	}
	if v := q.Get("since"); v != "" {
		n, perr := strconv.ParseInt(v, 10, 64)
		if perr != nil {

M internal/domain/session/repository.go => internal/domain/session/repository.go +5 -0
@@ 167,6 167,7 @@ type ListFilter struct {
	Owner  OwnerScope
	Tool   *string
	Host   *string
	Cwd    *string
	Since  *int64
	Until  *int64
	Limit  int


@@ 262,6 263,10 @@ func (r *Repository) List(ctx context.Context, f ListFilter) ([]Session, error) 
		clauses = append(clauses, "host = ?")
		args = append(args, *f.Host)
	}
	if f.Cwd != nil {
		clauses = append(clauses, "working_dir = ?")
		args = append(args, *f.Cwd)
	}
	if f.Since != nil {
		clauses = append(clauses, "started_at >= ?")
		args = append(args, *f.Since)

M internal/domain/session/repository_test.go => internal/domain/session/repository_test.go +40 -0
@@ 57,6 57,20 @@ func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, start
	}
}

// seedSessionWithCwd inserts a session row with a non-NULL working_dir so
// tests of the cwd filter can distinguish rows by their project path.
func seedSessionWithCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd string) {
	t.Helper()
	_, err := db.Exec(`
		INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
		owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/x.jsonl",
	)
	if err != nil {
		t.Fatalf("seed session %s/%s/%s/%s (cwd=%s): %v", owner, tool, host, sid, cwd, err)
	}
}

// seedTurn inserts a turn row directly via SQL. Optional columns are NULL.
func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content string) {
	t.Helper()


@@ 133,6 147,32 @@ func TestList_FilterByTimeRange(t *testing.T) {
	}
}

func TestList_FilterByCwd(t *testing.T) {
	repo, db := newRepo(t)
	// Two sessions share /code/x; one has /code/y; one has NULL working_dir.
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/y")
	seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s3", 1700000040, 1700000050, "/code/x")
	seedSession(t, db, "alice", "cc", "phoebe", "s4", 1700000060, 1700000070) // NULL cwd

	cwd := "/code/x"
	got, err := repo.List(context.Background(), session.ListFilter{
		Owner: session.OwnerScope{User: "alice"},
		Cwd:   &cwd,
		Limit: 50,
	})
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("expected 2 rows for cwd=/code/x; got %d (%#v)", len(got), got)
	}
	ids := map[string]bool{got[0].SessionID: true, got[1].SessionID: true}
	if !ids["s1"] || !ids["s3"] {
		t.Fatalf("expected {s1,s3}; got %v", ids)
	}
}

func TestList_FilterCombined(t *testing.T) {
	repo, db := newRepo(t)
	seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)

M internal/server/server.go => internal/server/server.go +3 -0
@@ 30,6 30,7 @@ import (

	"sourcecraft.dev/bigbes/lethe/internal/config"
	"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
	"sourcecraft.dev/bigbes/lethe/internal/domain/project"
	"sourcecraft.dev/bigbes/lethe/internal/domain/session"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"


@@ 56,6 57,7 @@ type Server struct {
	Auth     *authpkg.Authenticator `inject:""`
	Ingest   *ingest.Handler        `inject:""`
	Sessions *session.Handler       `inject:""`
	Projects *project.Handler       `inject:""`

	router   *chi.Mux
	httpSrv  *http.Server


@@ 97,6 99,7 @@ func (s *Server) Init(_ context.Context) error {
		r.Use(s.Auth.Middleware)
		s.Ingest.Mount(r)
		s.Sessions.Mount(r)
		s.Projects.Mount(r)
	})

	// SPA catch-all: serves the embedded React app for all non-API GET paths.