From 222ed294e023165371015aa8cf232d8b4ef21a2e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 15:11:18 -0400 Subject: [PATCH 01/20] feat(postgres): enable pg serve session curation and insight persistence (#183) --- internal/postgres/insights_pgtest_test.go | 83 +++++ internal/postgres/schema.go | 41 +++ internal/postgres/session_mgmt_pgtest_test.go | 99 +++++ internal/postgres/store.go | 340 +++++++++++++++--- internal/postgres/store_test.go | 166 ++++++--- internal/server/huma_routes_insights.go | 4 - internal/server/huma_routes_settings.go | 4 +- internal/server/server_test.go | 21 ++ 8 files changed, 662 insertions(+), 96 deletions(-) create mode 100644 internal/postgres/insights_pgtest_test.go create mode 100644 internal/postgres/session_mgmt_pgtest_test.go diff --git a/internal/postgres/insights_pgtest_test.go b/internal/postgres/insights_pgtest_test.go new file mode 100644 index 000000000..9762fee97 --- /dev/null +++ b/internal/postgres/insights_pgtest_test.go @@ -0,0 +1,83 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestStoreInsightCRUD(t *testing.T) { + pgURL := testPGURL(t) + ensureStoreSchema(t, pgURL) + + store, err := NewStore(pgURL, testSchema, true) + require.NoError(t, err, "NewStore") + defer store.Close() + + ctx := context.Background() + project := "insight-project" + cacheKey := "insight-cache-key" + + firstID, err := store.InsertInsight(db.Insight{ + Type: "daily_activity", + DateFrom: "2026-03-12", + DateTo: "2026-03-12", + Project: &project, + Agent: "claude", + Content: "first insight", + CacheKey: cacheKey, + CacheStatus: "fresh", + }) + require.NoError(t, err, "InsertInsight first") + require.NotZero(t, firstID) + + time.Sleep(10 * time.Millisecond) + + secondID, err := store.InsertInsight(db.Insight{ + Type: "daily_activity", + DateFrom: "2026-03-12", + DateTo: "2026-03-12", + Project: &project, + Agent: "claude", + Content: "second insight", + CacheKey: cacheKey, + CacheStatus: "hit", + }) + require.NoError(t, err, "InsertInsight second") + require.NotZero(t, secondID) + assert.NotEqual(t, firstID, secondID) + + listed, err := store.ListInsights(ctx, db.InsightFilter{ + Type: "daily_activity", + Project: project, + }) + require.NoError(t, err, "ListInsights") + require.Len(t, listed, 2) + assert.Equal(t, secondID, listed[0].ID) + assert.Equal(t, firstID, listed[1].ID) + + got, err := store.GetInsight(ctx, firstID) + require.NoError(t, err, "GetInsight") + require.NotNil(t, got) + assert.Equal(t, "first insight", got.Content) + require.NotNil(t, got.Project) + assert.Equal(t, project, *got.Project) + + cached, err := store.GetCachedInsight(ctx, cacheKey) + require.NoError(t, err, "GetCachedInsight") + require.NotNil(t, cached) + assert.Equal(t, secondID, cached.ID) + assert.Equal(t, "hit", cached.CacheStatus) + + require.NoError(t, store.DeleteInsight(firstID), "DeleteInsight first") + got, err = store.GetInsight(ctx, firstID) + require.NoError(t, err, "GetInsight after delete") + assert.Nil(t, got) +} diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index f23db04b4..1c4dbff4c 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -292,6 +292,35 @@ CREATE INDEX IF NOT EXISTS idx_secret_findings_session CREATE INDEX IF NOT EXISTS idx_secret_findings_rule ON secret_findings (rule_name); + +CREATE TABLE IF NOT EXISTS insights ( + id BIGSERIAL PRIMARY KEY, + type TEXT NOT NULL DEFAULT '', + date_from TEXT NOT NULL DEFAULT '', + date_to TEXT NOT NULL DEFAULT '', + project TEXT, + agent TEXT NOT NULL DEFAULT '', + model TEXT, + prompt TEXT, + content TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '', + schema_version TEXT NOT NULL DEFAULT '', + template_id TEXT NOT NULL DEFAULT '', + template_version TEXT NOT NULL DEFAULT '', + aggregate_hash TEXT NOT NULL DEFAULT '', + cache_key TEXT NOT NULL DEFAULT '', + cache_status TEXT NOT NULL DEFAULT '', + provenance_json TEXT NOT NULL DEFAULT '', + structured_json TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_insights_lookup + ON insights (type, date_from, date_to, project); + +CREATE INDEX IF NOT EXISTS idx_insights_cache + ON insights (cache_key, created_at DESC) + WHERE cache_key <> ''; ` // EnsureSchema creates the schema (if needed), then runs @@ -1587,6 +1616,18 @@ func CheckSchemaCompat( } rows.Close() + rows, err = db.QueryContext(ctx, + `SELECT id, type, date_from, date_to, project, agent, + model, prompt, content, kind, schema_version, + template_id, template_version, aggregate_hash, + cache_key, cache_status, provenance_json, + structured_json, created_at + FROM insights LIMIT 0`) + if err != nil { + return fmt.Errorf("insights table missing required columns: %w", err) + } + rows.Close() + if pgHasTable(ctx, db, "cursor_usage_events") { rows, err = db.QueryContext(ctx, `SELECT id, occurred_at, model, kind, diff --git a/internal/postgres/session_mgmt_pgtest_test.go b/internal/postgres/session_mgmt_pgtest_test.go new file mode 100644 index 000000000..54acdf752 --- /dev/null +++ b/internal/postgres/session_mgmt_pgtest_test.go @@ -0,0 +1,99 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestStoreSessionManagementCRUD(t *testing.T) { + pgURL := testPGURL(t) + ensureStoreSchema(t, pgURL) + + store, err := NewStore(pgURL, testSchema, true) + require.NoError(t, err, "NewStore") + defer store.Close() + + ctx := context.Background() + project := "session-mgmt" + _, err = store.DB().Exec(` + INSERT INTO sessions ( + id, machine, project, agent, first_message, + started_at, ended_at, message_count, + user_message_count + ) VALUES + ('mgmt-rename', 'machine', $1, 'claude', 'rename me', + '2026-03-12T10:00:00Z'::timestamptz, + '2026-03-12T10:30:00Z'::timestamptz, 2, 1), + ('mgmt-trash', 'machine', $1, 'claude', 'trash me', + '2026-03-12T11:00:00Z'::timestamptz, + '2026-03-12T11:30:00Z'::timestamptz, 2, 1), + ('mgmt-delete', 'machine', $1, 'claude', 'delete me', + '2026-03-12T12:00:00Z'::timestamptz, + '2026-03-12T12:30:00Z'::timestamptz, 2, 1), + ('mgmt-empty-a', 'machine', $1, 'claude', 'empty a', + '2026-03-12T13:00:00Z'::timestamptz, + '2026-03-12T13:30:00Z'::timestamptz, 2, 1), + ('mgmt-empty-b', 'machine', $1, 'claude', 'empty b', + '2026-03-12T14:00:00Z'::timestamptz, + '2026-03-12T14:30:00Z'::timestamptz, 2, 1) + `, project) + require.NoError(t, err, "inserting session rows") + + renamed := "Renamed by PG store" + require.NoError(t, store.RenameSession("mgmt-rename", &renamed), + "RenameSession") + sess, err := store.GetSession(ctx, "mgmt-rename") + require.NoError(t, err, "GetSession after rename") + require.NotNil(t, sess) + require.NotNil(t, sess.DisplayName) + assert.Equal(t, renamed, *sess.DisplayName) + + require.NoError(t, store.SoftDeleteSession("mgmt-trash"), + "SoftDeleteSession") + trashed, err := store.ListTrashedSessions(ctx) + require.NoError(t, err, "ListTrashedSessions after soft delete") + assert.Contains(t, sessionIDs(trashed), "mgmt-trash") + + restored, err := store.RestoreSession("mgmt-trash") + require.NoError(t, err, "RestoreSession") + assert.EqualValues(t, 1, restored) + sess, err = store.GetSession(ctx, "mgmt-trash") + require.NoError(t, err, "GetSession after restore") + require.NotNil(t, sess) + + require.NoError(t, store.SoftDeleteSession("mgmt-delete"), + "SoftDeleteSession delete target") + deleted, err := store.DeleteSessionIfTrashed("mgmt-delete") + require.NoError(t, err, "DeleteSessionIfTrashed") + assert.EqualValues(t, 1, deleted) + sess, err = store.GetSessionFull(ctx, "mgmt-delete") + require.NoError(t, err, "GetSessionFull after delete") + assert.Nil(t, sess) + + deletedCount, err := store.SoftDeleteSessions([]string{ + "mgmt-empty-a", "mgmt-empty-b", + }) + require.NoError(t, err, "SoftDeleteSessions") + assert.Equal(t, 2, deletedCount) + + count, err := store.EmptyTrash() + require.NoError(t, err, "EmptyTrash") + assert.Equal(t, 2, count) + trashed, err = store.ListTrashedSessions(ctx) + require.NoError(t, err, "ListTrashedSessions after empty trash") + assert.NotContains(t, sessionIDs(trashed), "mgmt-empty-a") + assert.NotContains(t, sessionIDs(trashed), "mgmt-empty-b") + + assert.Equal(t, db.ErrReadOnly, store.UpsertSession(db.Session{})) + assert.Equal(t, db.ErrReadOnly, + store.ReplaceSessionMessages("mgmt-rename", nil)) + _, err = store.WriteSessionBatchAtomic(nil) + assert.ErrorIs(t, err, db.ErrReadOnly) +} diff --git a/internal/postgres/store.go b/internal/postgres/store.go index 587ce835c..8a3dd7ba0 100644 --- a/internal/postgres/store.go +++ b/internal/postgres/store.go @@ -3,6 +3,8 @@ package postgres import ( "context" "database/sql" + "fmt" + "strings" "time" "go.kenn.io/agentsview/internal/config" @@ -13,7 +15,7 @@ import ( var _ db.Store = (*Store)(nil) // NewStore opens a PostgreSQL connection using the shared Open() -// helper and returns a read-only Store. +// helper and returns a Store. // When allowInsecure is true, non-loopback connections without // TLS produce a warning instead of failing. func NewStore( @@ -49,9 +51,9 @@ func (s *Store) SetCursorSecret(secret []byte) { s.cursorSecret = append([]byte(nil), secret...) } -// ReadOnly returns true because PG serve does not mutate synced -// session content. Small dashboard-owned curation metadata (stars -// and pins) is writable through dedicated methods. +// ReadOnly returns true because PG serve still treats the remote +// session store as remote; local file, upload, and batch-ingest +// paths stay blocked while dashboard curation uses dedicated methods. func (s *Store) ReadOnly() bool { return true } // GetSessionVersion returns the message count and a compact version @@ -76,81 +78,331 @@ func (s *Store) GetSessionVersion( // Unsupported write stubs (return db.ErrReadOnly) // ------------------------------------------------------------ -// InsertInsight is not supported in read-only mode. -func (s *Store) InsertInsight( - _ db.Insight, -) (int64, error) { - return 0, db.ErrReadOnly +const maxPGInsights = 500 + +type pgInsightRowScanner interface { + Scan(...any) error } -// DeleteInsight is not supported in read-only mode. -func (s *Store) DeleteInsight(_ int64) error { - return db.ErrReadOnly +func scanPGInsight(rs pgInsightRowScanner) (db.Insight, error) { + var s db.Insight + var project, model, prompt sql.NullString + var createdAt time.Time + err := rs.Scan( + &s.ID, &s.Type, &s.DateFrom, &s.DateTo, + &project, &s.Agent, &model, &prompt, &s.Content, + &s.Kind, &s.SchemaVersion, &s.TemplateID, + &s.TemplateVersion, &s.AggregateHash, &s.CacheKey, + &s.CacheStatus, &s.ProvenanceJSON, &s.StructuredJSON, + &createdAt, + ) + if err != nil { + return s, err + } + if project.Valid { + s.Project = &project.String + } + if model.Valid { + s.Model = &model.String + } + if prompt.Valid { + s.Prompt = &prompt.String + } + s.CreatedAt = FormatISO8601(createdAt) + return s, nil +} + +func buildPGInsightFilter( + f db.InsightFilter, pb *paramBuilder, +) string { + var preds []string + add := func(expr string, val any) { + preds = append(preds, expr+" = "+pb.add(val)) + } + if f.Type != "" { + add("type", f.Type) + } + if f.GlobalOnly { + preds = append(preds, "project IS NULL") + } else if f.Project != "" { + add("project", f.Project) + } + if f.DateFrom != "" { + preds = append(preds, "date_from >= "+pb.add(f.DateFrom)) + } + if f.DateTo != "" { + preds = append(preds, "date_to <= "+pb.add(f.DateTo)) + } + if len(preds) == 0 { + return "TRUE" + } + return strings.Join(preds, " AND ") } -// ListInsights returns an empty slice. Saved insights, including -// llm_canned structured metadata, are local SQLite artifacts; remote -// PG serve mode does not expose partial insight rows. +// InsertInsight stores a dashboard insight in PG. +func (s *Store) InsertInsight(insight db.Insight) (int64, error) { + var id int64 + err := s.pg.QueryRow( + `INSERT INTO insights ( + type, date_from, date_to, project, + agent, model, prompt, content, + kind, schema_version, template_id, + template_version, aggregate_hash, cache_key, + cache_status, provenance_json, structured_json + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, + $9, $10, $11, + $12, $13, $14, + $15, $16, $17 + ) RETURNING id`, + insight.Type, insight.DateFrom, insight.DateTo, insight.Project, + insight.Agent, insight.Model, insight.Prompt, insight.Content, + insight.Kind, insight.SchemaVersion, insight.TemplateID, + insight.TemplateVersion, insight.AggregateHash, insight.CacheKey, + insight.CacheStatus, insight.ProvenanceJSON, insight.StructuredJSON, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("inserting insight: %w", err) + } + return id, nil +} + +// DeleteInsight removes a dashboard insight from PG. +func (s *Store) DeleteInsight(id int64) error { + _, err := s.pg.Exec( + "DELETE FROM insights WHERE id = $1", + id, + ) + if err != nil { + return fmt.Errorf("deleting insight %d: %w", id, err) + } + return nil +} + +// ListInsights returns dashboard insights in created_at order. func (s *Store) ListInsights( - _ context.Context, _ db.InsightFilter, + ctx context.Context, f db.InsightFilter, ) ([]db.Insight, error) { - return []db.Insight{}, nil + pb := ¶mBuilder{} + where := buildPGInsightFilter(f, pb) + rows, err := s.pg.QueryContext(ctx, + `SELECT id, type, date_from, date_to, + project, agent, model, prompt, content, + kind, schema_version, template_id, + template_version, aggregate_hash, cache_key, + cache_status, provenance_json, structured_json, + created_at + FROM insights + WHERE `+where+` + ORDER BY created_at DESC, id DESC + LIMIT `+fmt.Sprintf("%d", maxPGInsights), + pb.args..., + ) + if err != nil { + return nil, fmt.Errorf("querying insights: %w", err) + } + defer rows.Close() + + insights := make([]db.Insight, 0) + for rows.Next() { + row, err := scanPGInsight(rows) + if err != nil { + return nil, fmt.Errorf("scanning insight: %w", err) + } + insights = append(insights, row) + } + return insights, rows.Err() } -// GetInsight returns nil because insights are local-only in PG serve mode. +// GetInsight returns a single insight by ID. func (s *Store) GetInsight( - _ context.Context, _ int64, + ctx context.Context, id int64, ) (*db.Insight, error) { - return nil, nil + row := s.pg.QueryRowContext(ctx, + `SELECT id, type, date_from, date_to, + project, agent, model, prompt, content, + kind, schema_version, template_id, + template_version, aggregate_hash, cache_key, + cache_status, provenance_json, structured_json, + created_at + FROM insights + WHERE id = $1`, + id, + ) + insight, err := scanPGInsight(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting insight %d: %w", id, err) + } + return &insight, nil } -// GetCachedInsight returns nil in read-only remote mode; this avoids -// returning incomplete cache/provenance metadata from PG-backed stores. +// GetCachedInsight returns the newest insight with the cache key. func (s *Store) GetCachedInsight( - _ context.Context, _ string, + ctx context.Context, cacheKey string, ) (*db.Insight, error) { - return nil, nil + if strings.TrimSpace(cacheKey) == "" { + return nil, nil + } + row := s.pg.QueryRowContext(ctx, + `SELECT id, type, date_from, date_to, + project, agent, model, prompt, content, + kind, schema_version, template_id, + template_version, aggregate_hash, cache_key, + cache_status, provenance_json, structured_json, + created_at + FROM insights + WHERE cache_key = $1 + ORDER BY created_at DESC, id DESC + LIMIT 1`, + cacheKey, + ) + insight, err := scanPGInsight(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("getting cached insight: %w", err) + } + return &insight, nil } -// RenameSession is not supported in read-only mode. +// RenameSession updates the visible session name in PG. func (s *Store) RenameSession( - _ string, _ *string, + id string, displayName *string, ) error { - return db.ErrReadOnly + _, err := s.pg.Exec( + `UPDATE sessions + SET display_name = $2, + updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + id, displayName, + ) + if err != nil { + return fmt.Errorf("renaming session %s: %w", id, err) + } + return nil } -// SoftDeleteSession is not supported in read-only mode. -func (s *Store) SoftDeleteSession(_ string) error { - return db.ErrReadOnly +// SoftDeleteSession moves a session to the trash. +func (s *Store) SoftDeleteSession(id string) error { + _, err := s.pg.Exec( + `UPDATE sessions + SET deleted_at = NOW(), + updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, + id, + ) + if err != nil { + return fmt.Errorf("soft deleting session %s: %w", id, err) + } + return nil } -// SoftDeleteSessions is not supported in read-only mode. -func (s *Store) SoftDeleteSessions(_ []string) (int, error) { - return 0, db.ErrReadOnly +// SoftDeleteSessions moves multiple sessions to the trash. +func (s *Store) SoftDeleteSessions(ids []string) (int, error) { + if len(ids) == 0 { + return 0, nil + } + total := 0 + const batchSize = 500 + for start := 0; start < len(ids); start += batchSize { + end := min(start+batchSize, len(ids)) + pb := ¶mBuilder{} + placeholders := make([]string, 0, end-start) + for _, id := range ids[start:end] { + placeholders = append(placeholders, pb.add(id)) + } + res, err := s.pg.Exec( + `UPDATE sessions + SET deleted_at = NOW(), + updated_at = NOW() + WHERE id IN (`+strings.Join(placeholders, ",")+ + `) AND deleted_at IS NULL`, + pb.args..., + ) + if err != nil { + return total, fmt.Errorf("soft deleting sessions: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return total, fmt.Errorf("counting soft deleted sessions: %w", err) + } + total += int(n) + } + return total, nil } -// RestoreSession is not supported in read-only mode. -func (s *Store) RestoreSession(_ string) (int64, error) { - return 0, db.ErrReadOnly +// RestoreSession restores a trashed session. +func (s *Store) RestoreSession(id string) (int64, error) { + res, err := s.pg.Exec( + `UPDATE sessions + SET deleted_at = NULL, + updated_at = NOW() + WHERE id = $1 AND deleted_at IS NOT NULL`, + id, + ) + if err != nil { + return 0, fmt.Errorf("restoring session %s: %w", id, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("counting restored session %s: %w", id, err) + } + return n, nil } -// DeleteSessionIfTrashed is not supported in read-only mode. +// DeleteSessionIfTrashed permanently deletes a trashed session. func (s *Store) DeleteSessionIfTrashed( - _ string, + id string, ) (int64, error) { - return 0, db.ErrReadOnly + res, err := s.pg.Exec( + `DELETE FROM sessions + WHERE id = $1 AND deleted_at IS NOT NULL`, + id, + ) + if err != nil { + return 0, fmt.Errorf("deleting trashed session %s: %w", id, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("counting deleted trashed session %s: %w", id, err) + } + return n, nil } -// ListTrashedSessions returns an empty slice. +// ListTrashedSessions returns trashed sessions in most-recent order. func (s *Store) ListTrashedSessions( - _ context.Context, + ctx context.Context, ) ([]db.Session, error) { - return []db.Session{}, nil + rows, err := s.pg.QueryContext(ctx, + "SELECT "+pgSessionCols+ + " FROM sessions WHERE deleted_at IS NOT NULL"+ + " ORDER BY deleted_at DESC LIMIT 500", + ) + if err != nil { + return nil, fmt.Errorf("querying trashed sessions: %w", err) + } + defer rows.Close() + return scanPGSessionRows(rows) } -// EmptyTrash is not supported in read-only mode. +// EmptyTrash permanently deletes every trashed session. func (s *Store) EmptyTrash() (int, error) { - return 0, db.ErrReadOnly + res, err := s.pg.Exec( + "DELETE FROM sessions WHERE deleted_at IS NOT NULL", + ) + if err != nil { + return 0, fmt.Errorf("emptying trash: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("counting emptied trash rows: %w", err) + } + return int(n), nil } // UpsertSession is not supported in read-only mode. diff --git a/internal/postgres/store_test.go b/internal/postgres/store_test.go index ec44d6762..e7033120a 100644 --- a/internal/postgres/store_test.go +++ b/internal/postgres/store_test.go @@ -105,6 +105,14 @@ func ensureAnalyticsTokenStoreSchema( require.NoError(t, err, "inserting analytics token sessions") } +func sessionIDs(sessions []db.Session) []string { + ids := make([]string, 0, len(sessions)) + for _, s := range sessions { + ids = append(ids, s.ID) + } + return ids +} + func TestNewStore(t *testing.T) { pgURL := testPGURL(t) ensureStoreSchema(t, pgURL) @@ -1326,56 +1334,122 @@ func TestStoreAnalyticsTopSessionsDurationExcludesReversedTimestamps(t *testing. "reversed duration row must be excluded") } -func TestStoreWriteMethodsReturnReadOnly(t *testing.T) { +func TestStoreWriteSurfaceSplitByCapability(t *testing.T) { pgURL := testPGURL(t) store, err := NewStore(pgURL, testSchema, true) require.NoError(t, err, "NewStore") defer store.Close() - tests := []struct { - name string - fn func() error - }{ - {"InsertInsight", func() error { - _, err := store.InsertInsight(db.Insight{}) - return err - }}, - {"DeleteInsight", func() error { - return store.DeleteInsight(1) - }}, - {"RenameSession", func() error { - return store.RenameSession("x", nil) - }}, - {"SoftDeleteSession", func() error { - return store.SoftDeleteSession("x") - }}, - {"RestoreSession", func() error { - _, err := store.RestoreSession("x") - return err - }}, - {"DeleteSessionIfTrashed", func() error { - _, err := store.DeleteSessionIfTrashed("x") - return err - }}, - {"EmptyTrash", func() error { - _, err := store.EmptyTrash() - return err - }}, - {"UpsertSession", func() error { - return store.UpsertSession(db.Session{}) - }}, - {"ReplaceSessionMessages", func() error { - return store.ReplaceSessionMessages("x", nil) - }}, - {"WriteSessionBatchAtomic", func() error { - _, err := store.WriteSessionBatchAtomic(nil) - return err - }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, db.ErrReadOnly, tt.fn()) - }) - } + assert.True(t, store.ReadOnly()) + + ctx := context.Background() + project := "store-capability" + sessionID := "store-capability-001" + trashedID := "store-capability-002" + emptyTrashID := "store-capability-003" + batchTrashID := "store-capability-004" + + insightID, err := store.InsertInsight(db.Insight{ + Type: "dashboard", + DateFrom: "2026-03-12", + DateTo: "2026-03-12", + Project: &project, + Agent: "claude", + Content: "insight content", + CacheKey: "capability-cache", + }) + require.NoError(t, err, "InsertInsight") + require.NotZero(t, insightID) + + insight, err := store.GetInsight(ctx, insightID) + require.NoError(t, err, "GetInsight") + require.NotNil(t, insight) + assert.Equal(t, project, *insight.Project) + + cached, err := store.GetCachedInsight(ctx, "capability-cache") + require.NoError(t, err, "GetCachedInsight") + require.NotNil(t, cached) + assert.Equal(t, insightID, cached.ID) + + listed, err := store.ListInsights(ctx, db.InsightFilter{ + Type: "dashboard", + }) + require.NoError(t, err, "ListInsights") + require.NotEmpty(t, listed) + assert.Equal(t, insightID, listed[0].ID) + + require.NoError(t, store.DeleteInsight(insightID), "DeleteInsight") + insight, err = store.GetInsight(ctx, insightID) + require.NoError(t, err, "GetInsight after delete") + assert.Nil(t, insight) + + _, err = store.DB().Exec(` + INSERT INTO sessions ( + id, machine, project, agent, first_message, + display_name, started_at, ended_at, message_count, + user_message_count + ) VALUES + ($1, 'machine', $2, 'claude', 'hello', + NULL, '2026-03-12T10:00:00Z'::timestamptz, + '2026-03-12T10:30:00Z'::timestamptz, 2, 1), + ($3, 'machine', $2, 'claude', 'trash me', + NULL, '2026-03-12T11:00:00Z'::timestamptz, + '2026-03-12T11:30:00Z'::timestamptz, 2, 1), + ($4, 'machine', $2, 'claude', 'empty trash me', + NULL, '2026-03-12T12:00:00Z'::timestamptz, + '2026-03-12T12:30:00Z'::timestamptz, 2, 1), + ($5, 'machine', $2, 'claude', 'batch trash me', + NULL, '2026-03-12T13:00:00Z'::timestamptz, + '2026-03-12T13:30:00Z'::timestamptz, 2, 1) + `, sessionID, project, trashedID, emptyTrashID, batchTrashID) + require.NoError(t, err, "inserting session rows") + + renamed := "Capability renamed session" + require.NoError(t, store.RenameSession(sessionID, &renamed), + "RenameSession") + sess, err := store.GetSession(ctx, sessionID) + require.NoError(t, err, "GetSession after rename") + require.NotNil(t, sess) + require.NotNil(t, sess.DisplayName) + assert.Equal(t, renamed, *sess.DisplayName) + + require.NoError(t, store.SoftDeleteSession(sessionID), + "SoftDeleteSession") + sess, err = store.GetSession(ctx, sessionID) + require.NoError(t, err, "GetSession after soft delete") + assert.Nil(t, sess) + trashed, err := store.ListTrashedSessions(ctx) + require.NoError(t, err, "ListTrashedSessions") + assert.Contains(t, sessionIDs(trashed), sessionID) + + restored, err := store.RestoreSession(sessionID) + require.NoError(t, err, "RestoreSession") + assert.EqualValues(t, 1, restored) + + require.NoError(t, store.SoftDeleteSession(trashedID), + "SoftDeleteSession trashedID") + deleted, err := store.DeleteSessionIfTrashed(trashedID) + require.NoError(t, err, "DeleteSessionIfTrashed") + assert.EqualValues(t, 1, deleted) + sess, err = store.GetSessionFull(ctx, trashedID) + require.NoError(t, err, "GetSessionFull after permanent delete") + assert.Nil(t, sess) + + require.NoError(t, store.SoftDeleteSessions([]string{ + emptyTrashID, batchTrashID, + }), "SoftDeleteSessions") + count, err := store.EmptyTrash() + require.NoError(t, err, "EmptyTrash") + assert.Equal(t, 2, count) + trashed, err = store.ListTrashedSessions(ctx) + require.NoError(t, err, "ListTrashedSessions after empty trash") + assert.NotContains(t, sessionIDs(trashed), emptyTrashID) + assert.NotContains(t, sessionIDs(trashed), batchTrashID) + + assert.Equal(t, db.ErrReadOnly, store.UpsertSession(db.Session{})) + assert.Equal(t, db.ErrReadOnly, + store.ReplaceSessionMessages("x", nil)) + _, err = store.WriteSessionBatchAtomic(nil) + assert.ErrorIs(t, err, db.ErrReadOnly) } diff --git a/internal/server/huma_routes_insights.go b/internal/server/huma_routes_insights.go index cd06e9133..5a0f4c708 100644 --- a/internal/server/huma_routes_insights.go +++ b/internal/server/huma_routes_insights.go @@ -178,10 +178,6 @@ func (s *Server) humaGenerateInsight( ctx context.Context, in *generateInsightInput, ) (*huma.StreamResponse, error) { - if s.db.ReadOnly() { - return nil, apiError(http.StatusNotImplemented, - "insight generation is not available in read-only mode") - } req := in.Body if !validInsightTypes[req.Type] { return nil, apiError(http.StatusBadRequest, diff --git a/internal/server/huma_routes_settings.go b/internal/server/huma_routes_settings.go index 3a4863df8..ef07a84a9 100644 --- a/internal/server/huma_routes_settings.go +++ b/internal/server/huma_routes_settings.go @@ -72,7 +72,7 @@ func (s *Server) humaGetSettings( Host: s.cfg.Host, Port: s.cfg.Port, RequireAuth: s.cfg.RequireAuth, - ReadOnly: s.db.ReadOnly(), + ReadOnly: s.engine == nil, } if isLocalhostContext(ctx) { resp.AuthToken = s.cfg.AuthToken @@ -86,7 +86,7 @@ func (s *Server) humaUpdateSettings( ctx context.Context, in *settingsInput, ) (*jsonOutput[settingsResponse], error) { - if s.db.ReadOnly() { + if s.engine == nil { return nil, apiError(http.StatusNotImplemented, "settings cannot be modified in read-only mode") } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7bb5b77ee..8a9caf0af 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2949,6 +2949,27 @@ func TestGetSettings_UsesGitHubCLIAuthTokenFallback(t *testing.T) { assert.True(t, resp.GithubConfigured) } +func TestSettingsRemainLockedInPGMode(t *testing.T) { + te := setupPGMode(t) + + w := te.get(t, "/api/v1/settings") + assertStatus(t, w, http.StatusOK) + type readOnlySettingsResponse struct { + ReadOnly bool `json:"read_only"` + } + resp := decode[readOnlySettingsResponse](t, w) + assert.True(t, resp.ReadOnly) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", + strings.NewReader(`{"require_auth":true}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://127.0.0.1:0") + w = httptest.NewRecorder() + te.handler.ServeHTTP(w, req) + assertStatus(t, w, http.StatusNotImplemented) + assertBodyContains(t, w, "settings cannot be modified") +} + func TestPublishSession_DoesNotUseGitHubCLIAuthTokenFallbackForForwardedRequest(t *testing.T) { useGitHubCLIAuthTokenStub(t) te := setup(t) From 2188a29a8c831bc2254d31aeb7da91f85dbdbd67 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 15:40:24 -0400 Subject: [PATCH 02/20] test(postgres): lock read-only dashboard write proofs (#183) --- internal/postgres/store_test.go | 6 ++- internal/server/insights_test.go | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/postgres/store_test.go b/internal/postgres/store_test.go index e7033120a..9a86d7e3e 100644 --- a/internal/postgres/store_test.go +++ b/internal/postgres/store_test.go @@ -1436,9 +1436,11 @@ func TestStoreWriteSurfaceSplitByCapability(t *testing.T) { require.NoError(t, err, "GetSessionFull after permanent delete") assert.Nil(t, sess) - require.NoError(t, store.SoftDeleteSessions([]string{ + deletedCount, err := store.SoftDeleteSessions([]string{ emptyTrashID, batchTrashID, - }), "SoftDeleteSessions") + }) + require.NoError(t, err, "SoftDeleteSessions") + assert.Equal(t, 2, deletedCount) count, err := store.EmptyTrash() require.NoError(t, err, "EmptyTrash") assert.Equal(t, 2, count) diff --git a/internal/server/insights_test.go b/internal/server/insights_test.go index 3e4672690..1ac82f2b2 100644 --- a/internal/server/insights_test.go +++ b/internal/server/insights_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strconv" "strings" "sync" @@ -18,6 +19,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/insight" "go.kenn.io/agentsview/internal/server" @@ -42,6 +44,20 @@ func (f roundTripFunc) RoundTrip( return f(req) } +type readOnlyInsightPersistStore struct { + db.Store + insertCalls int +} + +func (s *readOnlyInsightPersistStore) ReadOnly() bool { return true } + +func (s *readOnlyInsightPersistStore) InsertInsight( + insight db.Insight, +) (int64, error) { + s.insertCalls++ + return s.Store.InsertInsight(insight) +} + func newFailFirstWriteRecorder() *failFirstWriteRecorder { return &failFirstWriteRecorder{ header: make(http.Header), @@ -358,6 +374,62 @@ func TestGenerateInsight_DefaultAgent(t *testing.T) { assertBodyContains(t, w, "stub: no CLI") } +func TestGenerateInsight_PersistsWithReadOnlyStore(t *testing.T) { + dir := tempDirWithRetryCleanup(t) + dbPath := filepath.Join(dir, "test.db") + database, err := db.Open(dbPath) + require.NoError(t, err, "opening db") + t.Cleanup(func() { database.Close() }) + + store := &readOnlyInsightPersistStore{Store: database} + cfg := config.Config{ + Host: "127.0.0.1", + Port: 0, + DataDir: dir, + DBPath: dbPath, + WriteTimeout: 30 * time.Second, + } + srv := server.New(cfg, store, nil, server.WithGenerateFunc(func( + _ context.Context, agent, _ string, + ) (insight.Result, error) { + assert.Equal(t, "claude", agent) + return insight.Result{ + Agent: "claude", + Content: "persisted from read-only wrapper", + }, nil + })) + te := &testEnv{ + srv: srv, + handler: wrapTestHandler(cfg, srv.Handler()), + db: database, + engine: nil, + broadcaster: nil, + dataDir: dir, + } + + assert.True(t, store.ReadOnly()) + + w := te.post(t, "/api/v1/insights/generate", + `{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`) + assertStatus(t, w, http.StatusOK) + + events := parseSSE(w.Body.String()) + require.NotEmpty(t, events) + require.Equal(t, "done", events[len(events)-1].Event) + assert.Equal(t, 1, store.insertCalls) + + var saved db.Insight + require.NoError(t, json.Unmarshal([]byte(events[len(events)-1].Data), &saved)) + assert.Equal(t, "persisted from read-only wrapper", saved.Content) + assert.Equal(t, "claude", saved.Agent) + + stored, err := database.GetInsight(context.Background(), saved.ID) + require.NoError(t, err, "GetInsight after generate") + require.NotNil(t, stored) + assert.Equal(t, saved.ID, stored.ID) + assert.Equal(t, saved.Content, stored.Content) +} + func TestGenerateCannedInsight_RequiresExplicitOptIn(t *testing.T) { te := setup(t) From 708776cdd9fd2be01034dbe751887db327954e14 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 27 Jun 2026 15:51:25 -0400 Subject: [PATCH 03/20] fix(server): advertise pg insight-write capability (#183) --- cmd/agentsview/pg.go | 9 ++-- .../lib/api/generated/models/VersionInfo.ts | 2 +- frontend/src/lib/api/types/core.ts | 1 + .../activity/ActivityInsight.svelte | 9 ++-- .../activity/ActivityInsight.test.ts | 31 +++++++++-- .../components/insights/InsightsPage.svelte | 9 ++-- .../components/insights/InsightsPage.test.ts | 54 +++++++++++++------ internal/server/server.go | 13 ++--- internal/server/server_test.go | 8 +-- 9 files changed, 96 insertions(+), 40 deletions(-) diff --git a/cmd/agentsview/pg.go b/cmd/agentsview/pg.go index bfd45ca13..c49c322af 100644 --- a/cmd/agentsview/pg.go +++ b/cmd/agentsview/pg.go @@ -430,10 +430,11 @@ func runPGServe(appCfg config.Config, basePath string) { opts := []server.Option{ server.WithVersion(server.VersionInfo{ - Version: version, - Commit: commit, - BuildDate: buildDate, - ReadOnly: true, + Version: version, + Commit: commit, + BuildDate: buildDate, + ReadOnly: true, + InsightGenerationAvailable: true, }), server.WithDataDir(appCfg.DataDir), server.WithBaseContext(ctx), diff --git a/frontend/src/lib/api/generated/models/VersionInfo.ts b/frontend/src/lib/api/generated/models/VersionInfo.ts index db993dae1..c8109c321 100644 --- a/frontend/src/lib/api/generated/models/VersionInfo.ts +++ b/frontend/src/lib/api/generated/models/VersionInfo.ts @@ -7,7 +7,7 @@ export type VersionInfo = { build_date: string; commit: string; data_version: number; + insight_generation_available?: boolean; read_only?: boolean; version: string; }; - diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index e727125da..9cf2d254e 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -3,6 +3,7 @@ export interface VersionInfo { version: string; commit: string; build_date: string; + insight_generation_available?: boolean; read_only?: boolean; } diff --git a/frontend/src/lib/components/activity/ActivityInsight.svelte b/frontend/src/lib/components/activity/ActivityInsight.svelte index d6fd21bc5..5c6b6e285 100644 --- a/frontend/src/lib/components/activity/ActivityInsight.svelte +++ b/frontend/src/lib/components/activity/ActivityInsight.svelte @@ -55,12 +55,15 @@ router.navigate("insights"); } - const readOnly = $derived(sync.serverVersion?.read_only === true); + const insightGenerationAvailable = $derived( + sync.serverVersion?.insight_generation_available === true || + sync.serverVersion?.read_only !== true, + ); const generationUnavailable = $derived( - sync.serverVersion === null || readOnly, + sync.serverVersion === null || !insightGenerationAvailable, ); const unavailableTitle = $derived( - readOnly + sync.serverVersion !== null && !insightGenerationAvailable ? m.activity_insight_unavailable_read_only() : sync.serverVersion === null ? m.activity_insight_waiting_server() diff --git a/frontend/src/lib/components/activity/ActivityInsight.test.ts b/frontend/src/lib/components/activity/ActivityInsight.test.ts index e0a4e3c62..8bbc21e33 100644 --- a/frontend/src/lib/components/activity/ActivityInsight.test.ts +++ b/frontend/src/lib/components/activity/ActivityInsight.test.ts @@ -13,13 +13,20 @@ const mocks = vi.hoisted(() => ({ setAgent: vi.fn(), navigate: vi.fn(), agent: "claude" as string, - serverVersion: { read_only: false } as { read_only: boolean } | null, + serverVersion: { + read_only: false, + } as { + read_only: boolean; + insight_generation_available?: boolean; + } | null, })); vi.mock("../../api/generated/index", () => ({ InsightsService: { getApiV1Insights: mocks.getInsights }, })); vi.mock("../../api/runtime.js", () => ({ configureGeneratedClient: vi.fn() })); -vi.mock("../../api/client.js", () => ({ generateInsight: mocks.generateInsight })); +vi.mock("../../api/client.js", () => ({ + generateInsight: mocks.generateInsight, +})); vi.mock("../../stores/sync.svelte.js", () => ({ sync: { get serverVersion() { @@ -93,7 +100,10 @@ describe("ActivityInsight", () => { }); it("generates for the current range", async () => { - mocks.generateInsight.mockReturnValue({ abort: vi.fn(), done: new Promise(() => {}) }); + mocks.generateInsight.mockReturnValue({ + abort: vi.fn(), + done: new Promise(() => {}), + }); render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" }); await settle(); await fireEvent.click(screen.getByRole("button", { name: /generate/i })); @@ -178,7 +188,9 @@ describe("ActivityInsight", () => { it("prefills the Insights page range and navigates", async () => { render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" }); await settle(); - const link = screen.getByRole("link", { name: /insights page|open in insights/i }); + const link = screen.getByRole("link", { + name: /insights page|open in insights/i, + }); await fireEvent.click(link); expect(mocks.setType).toHaveBeenCalledWith("daily_activity"); expect(mocks.setDateFrom).toHaveBeenCalledWith("2026-06-15"); @@ -195,6 +207,17 @@ describe("ActivityInsight", () => { expect(btn.hasAttribute("disabled")).toBe(true); }); + it("allows Generate in read-only mode when insight generation is advertised", async () => { + mocks.serverVersion = { + read_only: true, + insight_generation_available: true, + }; + render(ActivityInsight, { dateFrom: "2026-06-15", dateTo: "2026-06-21" }); + await settle(); + const btn = screen.getByRole("button", { name: /generate/i }); + expect(btn.hasAttribute("disabled")).toBe(false); + }); + it("selects the exact-range insight over a newer nested one", async () => { mocks.getInsights.mockResolvedValue({ insights: [ diff --git a/frontend/src/lib/components/insights/InsightsPage.svelte b/frontend/src/lib/components/insights/InsightsPage.svelte index a8904b4d4..7c6b6d187 100644 --- a/frontend/src/lib/components/insights/InsightsPage.svelte +++ b/frontend/src/lib/components/insights/InsightsPage.svelte @@ -81,11 +81,12 @@ ); const loading = $derived(analytics.loading.signals); const error = $derived(analytics.errors.signals); - const readOnly = $derived( - sync.serverVersion?.read_only === true, + const insightGenerationAvailable = $derived( + sync.serverVersion?.insight_generation_available === true || + sync.serverVersion?.read_only !== true, ); const generationUnavailable = $derived( - sync.serverVersion === null || readOnly, + sync.serverVersion === null || !insightGenerationAvailable, ); const earliestSession = $derived(sync.stats?.earliest_session ?? null); const rangeSelection = $derived( @@ -1093,7 +1094,7 @@