From 2285c578382ca35a2f4dbdf16ea45ccb7c108849 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:12:38 +0300 Subject: [PATCH] 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 --- cmd/lethe/main.go | 7 +- cmd/lethe/main_e2e_test.go | 3 + internal/domain/project/handler.go | 145 +++++++++ internal/domain/project/handler_test.go | 161 ++++++++++ internal/domain/project/repository.go | 192 ++++++++++++ internal/domain/project/repository_test.go | 326 +++++++++++++++++++++ internal/domain/session/handler.go | 3 + internal/domain/session/repository.go | 5 + internal/domain/session/repository_test.go | 40 +++ internal/server/server.go | 3 + 10 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 internal/domain/project/handler.go create mode 100644 internal/domain/project/handler_test.go create mode 100644 internal/domain/project/repository.go create mode 100644 internal/domain/project/repository_test.go diff --git a/cmd/lethe/main.go b/cmd/lethe/main.go index 655cdee8fa316ba2d7ac981c0f678cd69b3d7ca8..13fed1ab574e9ea8eed666810d4065436f67342c 100644 --- a/cmd/lethe/main.go +++ b/cmd/lethe/main.go @@ -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()), ) diff --git a/cmd/lethe/main_e2e_test.go b/cmd/lethe/main_e2e_test.go index 11cac6bfbf7824b3eeb01b88fedefdc9c52996dd..905bd992010098bd4d66253cb49b114d80a86f2c 100644 --- a/cmd/lethe/main_e2e_test.go +++ b/cmd/lethe/main_e2e_test.go @@ -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()), ) diff --git a/internal/domain/project/handler.go b/internal/domain/project/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..2326a32ecb64ea64fbcfb3e9dcb0134e0fd97629 --- /dev/null +++ b/internal/domain/project/handler.go @@ -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 +} diff --git a/internal/domain/project/handler_test.go b/internal/domain/project/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6eaa54320daadea153ba8249d4b587feed72f79f --- /dev/null +++ b/internal/domain/project/handler_test.go @@ -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") + } +} diff --git a/internal/domain/project/repository.go b/internal/domain/project/repository.go new file mode 100644 index 0000000000000000000000000000000000000000..89c7eb15f6cab703634b6070c31b1a5ee83ad3ad --- /dev/null +++ b/internal/domain/project/repository.go @@ -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 +} diff --git a/internal/domain/project/repository_test.go b/internal/domain/project/repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..af1b96408fb47444fb1f0bbce4539fbcd9496e1a --- /dev/null +++ b/internal/domain/project/repository_test.go @@ -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) + } +} diff --git a/internal/domain/session/handler.go b/internal/domain/session/handler.go index 899600c3b6fc25e6aa21164ab46ec3839587e223..9418e20485d55bb7ae02083f8739afb68a3dcfac 100644 --- a/internal/domain/session/handler.go +++ b/internal/domain/session/handler.go @@ -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 { diff --git a/internal/domain/session/repository.go b/internal/domain/session/repository.go index f9fe0e4315d6500c3c5782502dd6f599e431718d..521ea5f5ee13ebaaa7cee793c21b3e88fd1a7204 100644 --- a/internal/domain/session/repository.go +++ b/internal/domain/session/repository.go @@ -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) diff --git a/internal/domain/session/repository_test.go b/internal/domain/session/repository_test.go index 8b98501abf306b6a2f9081830efcc779e6d5584a..5a95073a594f315a2f86bef81633e9d140435774 100644 --- a/internal/domain/session/repository_test.go +++ b/internal/domain/session/repository_test.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index 2f82c21f9cc51c286bb2875af3a4c52ac799867a..ef2d3f10d45855ac609a5c9693eb9286a74996b8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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.