From a78c53bdb0ed8fadda7ee431f4beee5b3d6ae353 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:00:24 -0500 Subject: [PATCH 1/2] feat(friction): build dated digests and catch up A completed day needs a stable digest even when the daemon was offline. Keep review opt-in and retain daily membership so resumed sessions and repeat runs do not inflate recurrence counts. Use the merged finding pipeline and renderer contracts. Freeze native usage alongside the digest without reintroducing the deferred persona and spend presentation. - fix(friction): drop catch-up snapshots after each save Co-authored-by: Wes McKinney --- cmd/agentsview/friction_schedule.go | 127 ++++ cmd/agentsview/friction_schedule_test.go | 313 +++++++++ cmd/agentsview/main.go | 5 + cmd/agentsview/pg.go | 14 +- docs/changelog.md | 7 + docs/configuration.md | 31 + docs/internal/jilog-adaptation.md | 56 +- frontend/src/App.test.ts | 1 + .../lib/api/generated/models/versionInfo.ts | 1 + .../analytics/AnalyticsPage.test.ts | 3 + .../lib/components/layout/AppHeader.test.ts | 2 + .../layout/ThreeColumnLayout.test.ts | 2 + .../lib/components/recall/RecallPage.test.ts | 2 + internal/apiclient/client.gen.go | 1 + internal/config/config.go | 59 +- internal/config/config_friction_test.go | 84 +++ internal/config/validation.go | 1 + internal/db/db.go | 6 + internal/db/friction_digest.go | 544 ++++++++++++++++ internal/db/friction_digest_test.go | 607 ++++++++++++++++++ internal/db/friction_usage.go | 187 ++++++ internal/db/friction_usage_test.go | 229 +++++++ internal/db/schema.sql | 42 ++ internal/friction/review/catchup_test.go | 305 +++++++++ internal/friction/review/dates.go | 23 + internal/friction/review/fold.go | 65 ++ internal/friction/review/fold_test.go | 48 ++ internal/friction/review/job.go | 27 + internal/friction/review/job_test.go | 61 ++ internal/friction/review/runner.go | 498 ++++++++++++++ internal/friction/review/runner_test.go | 487 ++++++++++++++ internal/friction/review/snapshot.go | 238 +++++++ internal/friction/review/snapshot_test.go | 79 +++ internal/postgres/backend.go | 1 + internal/postgres/friction_digest.go | 477 ++++++++++++++ .../postgres/friction_digest_pgtest_test.go | 284 ++++++++ .../postgres/friction_review_pgtest_test.go | 260 ++++++++ internal/postgres/friction_schema.go | 6 + internal/postgres/hosted_tenant.go | 2 +- internal/postgres/schema.go | 52 ++ internal/postgres/schema_test.go | 6 + internal/postgres/sessions.go | 2 + internal/server/friction_capability_test.go | 34 + internal/server/huma_routes.go | 6 +- internal/server/huma_routes_import.go | 6 +- internal/server/huma_routes_insights.go | 2 +- internal/server/huma_routes_metadata.go | 1 + .../server/huma_routes_sync_internal_test.go | 2 +- internal/server/insights.go | 2 +- internal/server/server.go | 11 + internal/sync/engine.go | 18 + internal/sync/engine_integration_test.go | 62 ++ openapi.yaml | 3 + 53 files changed, 5358 insertions(+), 34 deletions(-) create mode 100644 cmd/agentsview/friction_schedule.go create mode 100644 cmd/agentsview/friction_schedule_test.go create mode 100644 internal/db/friction_digest.go create mode 100644 internal/db/friction_digest_test.go create mode 100644 internal/db/friction_usage.go create mode 100644 internal/db/friction_usage_test.go create mode 100644 internal/friction/review/catchup_test.go create mode 100644 internal/friction/review/dates.go create mode 100644 internal/friction/review/fold.go create mode 100644 internal/friction/review/fold_test.go create mode 100644 internal/friction/review/job.go create mode 100644 internal/friction/review/job_test.go create mode 100644 internal/friction/review/runner.go create mode 100644 internal/friction/review/runner_test.go create mode 100644 internal/friction/review/snapshot.go create mode 100644 internal/friction/review/snapshot_test.go create mode 100644 internal/postgres/friction_digest.go create mode 100644 internal/postgres/friction_digest_pgtest_test.go create mode 100644 internal/postgres/friction_review_pgtest_test.go create mode 100644 internal/server/friction_capability_test.go diff --git a/cmd/agentsview/friction_schedule.go b/cmd/agentsview/friction_schedule.go new file mode 100644 index 0000000000..a0323b556b --- /dev/null +++ b/cmd/agentsview/friction_schedule.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "log" + "os" + "strings" + "sync" + "time" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/poller" + "go.kenn.io/agentsview/internal/server" + "go.kenn.io/agentsview/internal/timeutil" +) + +// frictionRunnerStore is a review store that can report whether it is writable. +type frictionRunnerStore interface { + review.Store +} + +type workTracker interface { + BeginWork() (func(), bool) +} + +func newFrictionRunner(cfg config.Config, store frictionRunnerStore, now func() time.Time) (*review.Runner, error) { + if !cfg.Friction.Enabled || store == nil { + return nil, nil + } + if local, ok := store.(interface{ ArchiveContent() config.ArchiveContent }); ok && local.ArchiveContent().UsageOnly() { + return nil, nil + } + if capable, ok := store.(interface{ FrictionAvailable() bool }); ok { + if !capable.FrictionAvailable() { + log.Printf("friction review disabled: this store cannot write friction tables") + return nil, nil + } + } else if store.ReadOnly() { + return nil, nil + } + zone := strings.TrimSpace(os.Getenv("AGENTSVIEW_FRICTION_TZ")) + if zone == "" { + zone = strings.TrimSpace(cfg.Friction.Timezone) + } + loc := timeutil.LocalLocation() + if store.ReadOnly() { + // Multiple hub processes must agree even when their host timezones differ. + loc = time.UTC + } + var err error + if zone != "" { + loc, err = time.LoadLocation(zone) + } + if err != nil { + return nil, err + } + patterns, err := friction.CompileSeatPatterns(cfg.Friction.SeatPatterns) + if err != nil { + return nil, err + } + return &review.Runner{ + Store: store, Loc: loc, Now: now, + BackfillDays: cfg.Friction.BackfillDays, SeatPatterns: patterns, + }, nil +} + +// frictionExclusive keeps a detached daemon alive and serializes digest work +// with local sync and resync. +func frictionExclusive(tracker workTracker, exclusive func(func() error) error) func(func() error) error { + return func(work func() error) error { + if tracker != nil { + done, ok := tracker.BeginWork() + if !ok { + return nil + } + defer done() + } + if exclusive == nil { + return work() + } + return exclusive(work) + } +} + +// serialExclusive serializes digest work within one pg serve process. +func serialExclusive() func(func() error) error { + var mu sync.Mutex + return func(work func() error) error { + mu.Lock() + defer mu.Unlock() + return work() + } +} + +// startFrictionReview starts the catch-up job when review writes are available. +// The caller cancels ctx before invoking the returned wait function. +func startFrictionReview( + ctx context.Context, cfg config.Config, store frictionRunnerStore, + exclusive func(func() error) error, +) (*review.Runner, func()) { + runner, err := newFrictionRunner(cfg, store, time.Now) + if err != nil { + log.Printf("friction review disabled: %v", err) + return nil, func() {} + } + if runner == nil { + return nil, func() {} + } + scheduler := poller.Start(ctx, review.Job(runner, exclusive)) + return runner, scheduler.Wait +} + +// startLocalFrictionReview shares the server's manual-sync barrier even when +// --no-sync leaves the background engine disabled. +func startLocalFrictionReview( + ctx context.Context, cfg config.Config, store frictionRunnerStore, + srv *server.Server, tracker workTracker, +) func() { + exclusive := frictionExclusive(tracker, func(work func() error) error { + return srv.SerializeArchiveWrite(ctx, work) + }) + runner, wait := startFrictionReview(ctx, cfg, store, exclusive) + server.WithFriction(runner)(srv) + return wait +} diff --git a/cmd/agentsview/friction_schedule_test.go b/cmd/agentsview/friction_schedule_test.go new file mode 100644 index 0000000000..615dd0c105 --- /dev/null +++ b/cmd/agentsview/friction_schedule_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "bufio" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "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/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/server" +) + +type readOnlyFrictionStore struct { + review.Store + readOnly bool + available *bool +} + +func (s readOnlyFrictionStore) ReadOnly() bool { return s.readOnly } + +type availableFrictionStore struct { + readOnlyFrictionStore +} + +func (s availableFrictionStore) FrictionAvailable() bool { return *s.available } + +func TestNewFrictionRunner(t *testing.T) { + d := dbtest.OpenTestDB(t) + yes, no := true, false + base := config.Config{ + Friction: config.FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}, + PublicURL: "https://av.example", + } + tests := []struct { + name string + cfg func() config.Config + store frictionRunnerStore + env string + wantNil bool + wantTZ string + }{ + {name: "disabled", cfg: func() config.Config { c := base; c.Friction.Enabled = false; return c }, store: d, wantNil: true}, + {name: "sqlite writable", cfg: func() config.Config { return base }, store: d, wantTZ: "Asia/Tokyo"}, + {name: "read-only sqlite", cfg: func() config.Config { return base }, store: readOnlyFrictionStore{Store: d, readOnly: true}, wantNil: true}, + { + name: "pg without write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &no}}, wantNil: true, + }, + { + name: "pg with write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &yes}}, wantTZ: "Asia/Tokyo", + }, + {name: "env overrides zone", cfg: func() config.Config { return base }, store: d, env: "Europe/Berlin", wantTZ: "Europe/Berlin"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", tt.env) + r, err := newFrictionRunner(tt.cfg(), tt.store, time.Now) + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, r) + return + } + require.NotNil(t, r) + assert.Equal(t, tt.wantTZ, r.Loc.String()) + assert.Equal(t, 30, r.BackfillDays) + }) + } + t.Setenv("AGENTSVIEW_FRICTION_TZ", "Not/AZone") + _, err := newFrictionRunner(base, d, time.Now) + require.Error(t, err) +} + +func TestFrictionReviewIsOptIn(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", "") + cfg, err := config.Default() + require.NoError(t, err) + d := dbtest.OpenTestDB(t) + r, err := newFrictionRunner(cfg, d, time.Now) + require.NoError(t, err) + assert.Nil(t, r) + cfg.Friction.Enabled = true + r, err = newFrictionRunner(cfg, d, time.Now) + require.NoError(t, err) + require.NotNil(t, r) + assert.Nil(t, r.Diagnostics) +} + +func TestFrictionReviewSkipsUsageOnlyArchive(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", "") + d := dbtest.OpenTestDB(t) + ended := time.Now().UTC().AddDate(0, 0, -1).Format(time.RFC3339) + dbtest.SeedSession(t, d, "retained", "proj", func(s *db.Session) { s.EndedAt = &ended }) + require.NoError(t, d.UpdateSessionSignals(t.Context(), "retained", db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{ + RulesVersion: friction.RulesVersion, Hash: "retained", + Findings: []db.FrictionFinding{{ + SessionID: "retained", Kind: "correction", Detector: "correction.coding", + Text: "Use the existing helper", Title: "Use the existing helper", Fingerprint: "correction-helper", + RulesVersion: friction.RulesVersion, + }}, + }, + })) + d.SetArchiveContent(config.ArchiveContentUsage) + cfg := config.Config{NoSync: true, Friction: config.FrictionConfig{Enabled: true, Timezone: "UTC"}} + ctx, cancel := context.WithCancel(t.Context()) + runner, wait := startFrictionReview(ctx, cfg, d, nil) + t.Cleanup(func() { cancel(); wait() }) + require.Nil(t, runner, "a usage-only archive must not start digest work") + got, err := d.GetFrictionDigest(t.Context(), ended[:10]) + require.NoError(t, err) + assert.Nil(t, got) +} + +// Pause after selecting real subjects, before retrieving their findings. +type pausedFrictionStore struct { + *db.DB + selected chan struct{} + resume <-chan struct{} +} + +func (s pausedFrictionStore) FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) { + subjects, err := s.DB.FrictionSubjectsForDate(ctx, date, loc, includeDigested) + close(s.selected) + select { + case <-s.resume: + return subjects, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func TestLocalFrictionReviewNoSyncSharesResyncBarrier(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", "") + d := dbtest.OpenTestDB(t) + ended := time.Now().UTC().AddDate(0, 0, -1).Format(time.RFC3339) + dbtest.SeedSession(t, d, "s", "proj", func(s *db.Session) { s.EndedAt = &ended }) + require.NoError(t, d.UpdateSessionSignals(t.Context(), "s", db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{ + RulesVersion: friction.RulesVersion, Hash: "s", + Findings: []db.FrictionFinding{{ + SessionID: "s", Kind: "correction", Detector: "correction.coding", + Text: "Use the existing helper", Title: "Use the existing helper", Fingerprint: "correction-helper", + RulesVersion: friction.RulesVersion, + }}, + }, + })) + cfg := config.Config{ + Host: "127.0.0.1", DataDir: t.TempDir(), DBPath: d.Path(), NoSync: true, + Friction: config.FrictionConfig{Enabled: true, Timezone: "UTC", BackfillDays: 1}, + } + srv := server.New(cfg, d, nil, server.WithBaseContext(t.Context())) + httpServer := httptest.NewServer(srv.Handler()) + t.Cleanup(httpServer.Close) + selected, resume := make(chan struct{}), make(chan struct{}) + release := sync.OnceFunc(func() { close(resume) }) + ctx, cancel := context.WithCancel(t.Context()) + wait := startLocalFrictionReview(ctx, cfg, pausedFrictionStore{d, selected, resume}, srv, nil) + t.Cleanup(func() { + release() + cancel() + wait() + require.NoError(t, srv.Shutdown(context.WithoutCancel(t.Context()))) + }) + select { + case <-selected: + case <-time.After(5 * time.Second): + require.FailNow(t, "digest did not select the seeded session") + } + + requestCtx, requestCancel := context.WithTimeout(t.Context(), 10*time.Second) + defer requestCancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, httpServer.URL+"/api/v1/resync", nil) + require.NoError(t, err) + req.Host = "127.0.0.1:0" + req.Header.Set("Origin", "http://127.0.0.1:0") + req.Header.Set("Accept", "text/event-stream") + response, err := httpServer.Client().Do(req) + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + reader := bufio.NewReader(response.Body) + event, err := reader.ReadString('\n') + require.NoError(t, err) + assert.Equal(t, "event: progress\n", event) + progress, err := reader.ReadString('\n') + require.NoError(t, err) + assert.Contains(t, progress, "Waiting for the current sync to finish") + + release() + remaining, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Contains(t, string(remaining), "event: done\n") + assert.NotContains(t, string(remaining), "event: error\n") + got, err := d.GetFrictionDigest(t.Context(), ended[:10]) + require.NoError(t, err) + require.NotNil(t, got) + assert.Contains(t, string(got.Markdown), "Use the existing helper", + "the digest must capture findings before resync replaces the archive") +} + +type fakeTracker struct { + active, max int + draining bool +} + +func (f *fakeTracker) BeginWork() (func(), bool) { + if f.draining { + return func() {}, false + } + f.active++ + f.max = max(f.max, f.active) + return func() { f.active-- }, true +} + +type fakeExclusive struct{ calls int } + +func (f *fakeExclusive) RunExclusive(work func() error) error { f.calls++; return work() } + +func TestFrictionExclusiveKeepsDaemonAlive(t *testing.T) { + tracker := &fakeTracker{} + lock := &fakeExclusive{} + excl := frictionExclusive(tracker, lock.RunExclusive) + sawActive := false + require.NoError(t, excl(func() error { sawActive = tracker.active == 1; return nil })) + assert.True(t, sawActive, "the idle tracker counts the job as work while it runs") + assert.Equal(t, 0, tracker.active) + assert.Equal(t, 1, lock.calls, "work runs under engine.RunExclusive") + + boom := errors.New("boom") + require.ErrorIs(t, excl(func() error { return boom }), boom) + + tracker.draining = true + ran := false + require.NoError(t, excl(func() error { ran = true; return nil })) + assert.False(t, ran, "a draining daemon skips the job") + + direct := frictionExclusive(nil, nil) + require.NoError(t, direct(func() error { ran = true; return nil })) + assert.True(t, ran) +} + +func TestSerialExclusive(t *testing.T) { + excl := serialExclusive() + inside := make(chan struct{}) + release := make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + go func() { _ = excl(func() error { close(inside); <-release; return nil }) }() + <-inside + second := make(chan struct{}) + go func() { _ = excl(func() error { close(second); return nil }) }() + assert.Never(t, func() bool { + select { + case <-second: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond, "second call entered while the first held the lock") + close(release) + require.Eventually(t, func() bool { + select { + case <-second: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond, "second call never ran") +} + +func TestHubFrictionDefaultsToUTCDates(t *testing.T) { + t.Setenv("TZ", "Pacific/Honolulu") + t.Setenv("AGENTSVIEW_FRICTION_TZ", "") + d := dbtest.OpenTestDB(t) + ended := "2026-09-21T12:00:00Z" + dbtest.SeedSession(t, d, "s", "proj", func(s *db.Session) { s.EndedAt = &ended }) + require.NoError(t, d.UpdateSessionSignals(t.Context(), "s", db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{RulesVersion: friction.RulesVersion, Hash: "s"}, + })) + available := true + store := availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &available}} + cfg := config.Config{Friction: config.FrictionConfig{Enabled: true}} + r, err := newFrictionRunner(cfg, store, func() time.Time { + return time.Date(2026, 9, 22, 7, 0, 0, 0, time.UTC) + }) + require.NoError(t, err) + require.NotNil(t, r) + _, err = r.CatchUp(t.Context()) + require.NoError(t, err) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + require.NotNil(t, got, "hub dates must not depend on the serve process's local zone") + assert.Equal(t, "UTC", got.Timezone) + assert.Equal(t, 1, got.SessionsScanned) +} diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index 2bb8adb634..237ef40121 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -619,6 +619,11 @@ func runServe(ctx context.Context, cfg config.Config, opts serveOptions, restart newDaemonArtifactExchangeRunner(cfg, database, engine, emitter), )) srv := server.New(cfg, database, engine, srvOpts...) + waitFriction := startLocalFrictionReview(ctx, cfg, database, srv, idleTracker) + defer func() { + stop() + waitFriction() + }() stopAudit := func() {} if engine != nil { diff --git a/cmd/agentsview/pg.go b/cmd/agentsview/pg.go index 7a25dfd4a1..6ad555d4f7 100644 --- a/cmd/agentsview/pg.go +++ b/cmd/agentsview/pg.go @@ -48,5 +48,17 @@ func (pgReplica) serveOptions( if rawSyncOption != nil { opts = append(opts, rawSyncOption) } - return opts, closeRawSync, nil + frictionExcl := serialExclusive() + frictionRunner, waitFriction := startFrictionReview(ctx, appCfg, pgStore, frictionExcl) + if frictionRunner != nil { + opts = append(opts, server.WithFriction(frictionRunner)) + } + closeAll := func() error { + waitFriction() + if closeRawSync != nil { + return closeRawSync() + } + return nil + } + return opts, closeAll, nil } diff --git a/docs/changelog.md b/docs/changelog.md index d64b3e16b7..22f9325038 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -50,6 +50,13 @@ The latest release is using version 4 must switch to version 5, obtain new project keys, and refresh saved digests. Version 3 is unchanged. +- When Friction Log review is enabled, AgentsView stores daily digests and + catches up on completed days missed while offline, including sessions that + arrive late within the configured backfill window. Digests retain findings, + usage, and pattern recurrence, and survive full resyncs that retain transcript + text. PostgreSQL hubs build their own digests. Read APIs and CLI commands + remain planned. + - Browse and search fx sessions, including tool results and child runs. Schema 4 sessions sync from `~/.fx/sessions/`, with token costs from the pricing catalog. - Assistants connected over MCP can see which tools a session ran, find which diff --git a/docs/configuration.md b/docs/configuration.md index a00cb8814e..51305db147 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2006,6 +2006,9 @@ transcript a second time. ```toml [friction] enabled = true +backfill_days = 7 +timezone = "" # Local time on SQLite, UTC on PostgreSQL; or an IANA zone. +seat_patterns = [] # Optional path globs with one whole-segment {seat} capture. ``` Restart the daemon after changing this setting. When enabled: @@ -2024,6 +2027,34 @@ Restart the daemon after changing this setting. When enabled: PostgreSQL push. A `--no-sync` daemon also catches up after it restarts with automatic sync. +An hourly job builds one stored digest per completed local day, catching up +within `backfill_days` (1–3650, default 7). It never builds today. Set +`AGENTSVIEW_FRICTION_TZ` to override the configured timezone. Seat patterns label +digest findings from session paths without changing the stored detections. + +Digests keep Markdown, schema 1 summary JSON, frozen findings and usage, +session membership, and pattern recurrence. SQLite keeps this state through a +full resync when the archive retains transcript text. Usage-only SQLite archives +do not build or update digests. PostgreSQL builds its own digests from pushed +findings when its serving role can write the review tables. Read APIs and CLI +commands arrive in a later slice; this change stores the digests but does not +expose a digest page. + +Local days wait until their sessions have current findings; the wait does not +expire. Other completed days can build while review catches up. PostgreSQL uses +the findings pushed by each source, including other rules versions, and skips +sessions whose source has not enabled review. Sessions over the review budget +are excluded immediately. + +Catch-up fills missing dates throughout `backfill_days`, including empty days, +and revisits dates with unrecorded sessions inside that window, so late +syncs and pushes update an existing digest. A session belongs to only one digest +even if it resumes later, and rebuilding does not count its patterns twice. +Sessions that arrive or finish review outside the window require a larger +`backfill_days` setting. Each daily build holds the sync lock only for that day. +PostgreSQL defaults to UTC; configure the same timezone on every process serving +the same hub. + Hosted raw sync reviews sessions as it projects them, whatever this setting says. See [Hosted Raw Sync](/docs/hosted-raw-sync/). diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index 1c8e8304fb..fbddabe68e 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -8,8 +8,9 @@ notice. Source paths below are relative to that jilog commit. The pure detection, signal, formatting, JSON, archived-row adapter, session review, and digest renderer are built, and sync stores each session's findings. -Scheduling, NanoClaw integration, and Kata filing remain planned. Sections -marked planned record the approved mapping, not current behavior. +Daily scheduling and stored digests are built. NanoClaw integration and Kata +filing remain planned. Sections marked planned record the approved mapping, not +current behavior. ## Kept @@ -39,11 +40,6 @@ marked planned record the approved mapping, not current behavior. ## Planned replacements -- Database tables replace the processed-sessions file and retry sidecar. A - catch-up job will build each completed local day once, replacing the nightly - run. -- A direct usage-rollup query will replace the `agentsview usage daily` - shell-out for archive spend. - A native Kata HTTP client will replace the Kata CLI tracker. Filing remains off until a Kata hub is configured. - Generic NanoClaw persona and channel resolution, trust filtering, and @@ -81,8 +77,7 @@ marked planned record the approved mapping, not current behavior. markers and interrupted-turn rows. They do not change a jilog kind. - Archived tool calls supply error and pattern findings. jilog's AgentsView reader cannot produce those findings from its session rows. -- Detection and digests are planned to be on by default, as running jilog makes - them. P0 filing awaits the archive and Kata integration. +- Detection and digests are opt-in. P0 filing awaits Kata integration. ## Deliberate differences @@ -135,9 +130,9 @@ Additions beyond jilog: - Frustration markers (`signals.IsFrustrationMarker`) and user interruptions (rows the Claude parser tags `interrupted`) are friction kinds of their own. They run after jilog's five kinds. -- `SeatFromPath` uses only the patterns its caller provides. Wiring those - patterns to `[friction] seat_patterns` configuration is planned for a later - PR. jilog's built-in pool-directory conventions are not carried over. +- `SeatFromPath` uses only the patterns its caller provides. The digest runner + reads those patterns from `[friction] seat_patterns`. jilog's built-in + pool-directory conventions are not carried over. ## Parity notes @@ -165,15 +160,40 @@ The digest follows jilog's `learning-digest.md` layout with these differences: - Personas, spend, issue links and recurrence costs await the slices that supply them. Timezone handling belongs to daily digest scheduling. - Archived text renders in code spans that preserve backticks and boundary - spaces. Control characters become spaces. Markdown and HTML render literally. - Stored pattern evidence and the detector rules version stay unchanged. + spaces. Control characters become spaces. Markdown and HTML render + literally. Stored pattern evidence and the detector rules version stay + unchanged. - The JSON summary uses AgentsView schema 1 with `date`, `schema_version`, `sessions_scanned`, seven kind counts and `p0_alerts`. -`friction-log.md` and `summary.json` share one archive-shaped fixture with -error text, retry and runaway patterns, and two P0 tools across three -sessions each. `friction-log-empty.md` pins every empty section. The Markdown -frontmatter and JSON counts use the same signal grouping. +`friction-log.md` and `summary.json` share one archive-shaped fixture with error +text, retry and runaway patterns, and two P0 tools across three sessions each. +`friction-log-empty.md` pins every empty section. The Markdown frontmatter and +JSON counts use the same signal grouping. + +## Review orchestration + +The daily runner adapts jilog `9e8e094` +`crates/jilog-review/src/digest.rs:208-706` (`run_review`), `reader.rs:233-270` +(processed sessions), and `archive_spend.rs:83-109`. + +- Database tables replace the processed-sessions file. An hourly catch-up builds + completed dates and revisits dates with late sessions within the configured + backfill window. Local dates wait for current findings without a timeout; + PostgreSQL accepts each source's stored rules version. Each daily build has + its own sync lock, and PostgreSQL defaults to UTC. +- Subjects use their last activity date. Rebuilds retain prior membership, and + recurrence counts include each subject once. Dry runs write nothing. +- Frozen snapshots retain findings, usage totals, and the preceding week's + native archive spend. Costs use archive microdollars; sub-agent spend uses + the `subagent` role. The merged Markdown and schema 1 JSON renderers remain + unchanged; spend display and persona integration are deferred. +- The runner respects the merged finding pipeline's skipped-session marker. It + derives seat labels from configured paths without a dimensions table or + changes to detector fingerprints. Generic diagnostic subjects remain an + optional input and never raise P0 alerts. +- SQLite preserves digest state during resyncs that retain transcript text. + PostgreSQL builds its own digests and checks write access before scheduling. ## License diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 65af6a041c..82e05db465 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -220,6 +220,7 @@ describe("App Recall availability", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/api/generated/models/versionInfo.ts b/frontend/src/lib/api/generated/models/versionInfo.ts index 09983209e7..2941bdb253 100644 --- a/frontend/src/lib/api/generated/models/versionInfo.ts +++ b/frontend/src/lib/api/generated/models/versionInfo.ts @@ -8,6 +8,7 @@ export interface VersionInfo { build_date: string; commit: string; data_version: number; + friction_available: boolean; insight_generation_available: boolean; read_only?: boolean; session_stats_available: boolean; diff --git a/frontend/src/lib/components/analytics/AnalyticsPage.test.ts b/frontend/src/lib/components/analytics/AnalyticsPage.test.ts index 16f32e2038..0c11a8ee1f 100644 --- a/frontend/src/lib/components/analytics/AnalyticsPage.test.ts +++ b/frontend/src/lib/components/analytics/AnalyticsPage.test.ts @@ -160,6 +160,7 @@ describe("AnalyticsPage outcome window", () => { api_version: 10, data_version: 1, insight_generation_available: false, + friction_available: false, session_stats_available: backendAvailable, }; router.route = "sessions"; @@ -195,6 +196,7 @@ describe("AnalyticsPage outcome window", () => { api_version: 10, data_version: 1, insight_generation_available: false, + friction_available: false, session_stats_available: true, read_only: true, }; @@ -1072,6 +1074,7 @@ describe("AnalyticsPage refresh behavior", () => { api_version: 10, data_version: 1, insight_generation_available: false, + friction_available: false, session_stats_available: true, }; router.params = { date_from: "2026-03-01", date_to: "2026-03-31" }; diff --git a/frontend/src/lib/components/layout/AppHeader.test.ts b/frontend/src/lib/components/layout/AppHeader.test.ts index 4358c66ddd..b09ca46894 100644 --- a/frontend/src/lib/components/layout/AppHeader.test.ts +++ b/frontend/src/lib/components/layout/AppHeader.test.ts @@ -308,6 +308,7 @@ describe("AppHeader export actions", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -333,6 +334,7 @@ describe("AppHeader export actions", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts b/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts index b74111aba9..fd15033806 100644 --- a/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts +++ b/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts @@ -189,6 +189,7 @@ describe("ThreeColumnLayout", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -217,6 +218,7 @@ describe("ThreeColumnLayout", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/components/recall/RecallPage.test.ts b/frontend/src/lib/components/recall/RecallPage.test.ts index 6784e3941c..32488ae183 100644 --- a/frontend/src/lib/components/recall/RecallPage.test.ts +++ b/frontend/src/lib/components/recall/RecallPage.test.ts @@ -20,6 +20,7 @@ describe("RecallPage", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -72,6 +73,7 @@ describe("RecallPage", () => { api_version: 1, data_version: 1, session_stats_available: false, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 6e99b0bc51..bed0b87ef1 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -25793,6 +25793,7 @@ type VersionInfo struct { BuildDate string `json:"build_date" validate:"required"` Commit string `json:"commit" validate:"required"` DataVersion int64 `json:"data_version"` + FrictionAvailable bool `json:"friction_available"` InsightGenerationAvailable bool `json:"insight_generation_available"` ReadOnly *bool `json:"read_only,omitempty"` SessionStatsAvailable bool `json:"session_stats_available"` diff --git a/internal/config/config.go b/internal/config/config.go index dd3621c49b..c3f65d7238 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ import ( "github.com/BurntSushi/toml" "github.com/gofrs/flock" "github.com/spf13/pflag" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/jsonutil" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/pathutil" @@ -644,11 +645,44 @@ type AutomatedConfig struct { ExactMatches []string `toml:"exact_matches" json:"exact_matches,omitempty"` } -// FrictionConfig controls Friction Log analysis. It is off by default because -// reviewing transcripts costs CPU that users without a digest do not need. +// DefaultFrictionBackfillDays bounds catch-up to recent complete local dates. +const DefaultFrictionBackfillDays = 7 + +// FrictionConfig controls the opt-in Friction Log review. type FrictionConfig struct { // Enabled reviews changed sessions in the background after each sync. Enabled bool `toml:"enabled" json:"enabled,omitempty"` + // Timezone is the IANA zone digests are dated in. An empty value uses + // the local zone; AGENTSVIEW_FRICTION_TZ overrides this setting. + Timezone string `json:"timezone,omitempty" toml:"timezone"` + // BackfillDays bounds catch-up to recent complete local dates. + BackfillDays int `json:"backfill_days" toml:"backfill_days"` + // SeatPatterns are globs over session file paths with one {seat} capture. + SeatPatterns []string `json:"seat_patterns,omitempty" toml:"seat_patterns"` +} + +// Validate checks the zone name, backfill bound, and seat patterns. +func (c FrictionConfig) Validate() error { + if tz := strings.TrimSpace(c.Timezone); tz != "" { + if tz == "Local" { + return fmt.Errorf("[friction] timezone %q is not an IANA zone", tz) + } + if _, err := time.LoadLocation(tz); err != nil { + return fmt.Errorf("[friction] timezone %q is not an IANA zone", tz) + } + } + if c.BackfillDays < 1 || c.BackfillDays > 3650 { + return errors.New("[friction] backfill_days must be between 1 and 3650") + } + for _, p := range c.SeatPatterns { + if strings.Count(p, "{seat}") != 1 { + return fmt.Errorf("[friction] seat_patterns entry %q must contain exactly one {seat}", p) + } + } + if _, err := friction.CompileSeatPatterns(c.SeatPatterns); err != nil { + return fmt.Errorf("[friction] seat_patterns: %w", err) + } + return nil } // AgentConfig holds per-agent runtime overrides. @@ -1346,6 +1380,7 @@ func Default() (Config, error) { FailureBackoff: "1h", }, }, + Friction: FrictionConfig{BackfillDays: DefaultFrictionBackfillDays}, }, nil } @@ -2075,6 +2110,23 @@ func (c *Config) applyConfigTOML(data string) error { strings.TrimSpace(c.Insights.DefaultAgent), ) } + if meta.IsDefined("friction", "enabled") { + c.Friction.Enabled = file.Friction.Enabled + } + if meta.IsDefined("friction", "timezone") { + c.Friction.Timezone = strings.TrimSpace(file.Friction.Timezone) + } + if meta.IsDefined("friction", "backfill_days") { + c.Friction.BackfillDays = file.Friction.BackfillDays + } + if meta.IsDefined("friction", "seat_patterns") { + c.Friction.SeatPatterns = make([]string, 0, len(file.Friction.SeatPatterns)) + for _, p := range file.Friction.SeatPatterns { + if p = strings.TrimSpace(p); p != "" { + c.Friction.SeatPatterns = append(c.Friction.SeatPatterns, p) + } + } + } // IsDefined distinguishes "unset" (leave default 10s) from an // explicit "0s" (disable coalescing). Checking != 0 would silently // ignore the latter. @@ -2084,9 +2136,6 @@ func (c *Config) applyConfigTOML(data string) error { if meta.IsDefined("daemon_idle_timeout") { c.DaemonIdleTimeout = file.DaemonIdleTimeout } - if meta.IsDefined("friction", "enabled") { - c.Friction.Enabled = file.Friction.Enabled - } if file.Automated.Prefixes != nil { c.Automated.Prefixes = file.Automated.Prefixes } diff --git a/internal/config/config_friction_test.go b/internal/config/config_friction_test.go index 2059b4ba39..c2fb0f07a4 100644 --- a/internal/config/config_friction_test.go +++ b/internal/config/config_friction_test.go @@ -4,8 +4,92 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestFrictionConfigValidate(t *testing.T) { + tests := []struct { + name string + config FrictionConfig + wantErr string + }{ + {name: "defaults", config: FrictionConfig{Enabled: true, BackfillDays: 7}}, + {name: "zone", config: FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}}, + {name: "empty zone means local", config: FrictionConfig{Enabled: true, BackfillDays: 1}}, + {name: "upper bound", config: FrictionConfig{BackfillDays: 3650}}, + {name: "seat pattern", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"*/profiles/{seat}/projects/*"}}}, + {name: "bad zone", config: FrictionConfig{Timezone: "Mars/Olympus", BackfillDays: 7}, wantErr: `[friction] timezone "Mars/Olympus" is not an IANA zone`}, + {name: "local is not an IANA zone", config: FrictionConfig{Timezone: "Local", BackfillDays: 7}, wantErr: `[friction] timezone "Local" is not an IANA zone`}, + {name: "zero backfill", config: FrictionConfig{BackfillDays: 0}, wantErr: "[friction] backfill_days must be between 1 and 3650"}, + {name: "huge backfill", config: FrictionConfig{BackfillDays: 3651}, wantErr: "[friction] backfill_days must be between 1 and 3650"}, + { + name: "seat pattern without capture", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"*/profiles/*"}}, + wantErr: `[friction] seat_patterns entry "*/profiles/*" must contain exactly one {seat}`, + }, + { + name: "seat pattern with two captures", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"{seat}/{seat}"}}, + wantErr: `[friction] seat_patterns entry "{seat}/{seat}" must contain exactly one {seat}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + }) + } +} + +func TestFrictionConfigTOMLLoadAndFinalize(t *testing.T) { + tests := []struct { + name string + data map[string]any + want FrictionConfig + }{ + { + name: "absent section means disabled", data: map[string]any{}, + want: FrictionConfig{BackfillDays: DefaultFrictionBackfillDays}, + }, + { + name: "all keys trimmed", + data: map[string]any{"friction": map[string]any{ + "enabled": true, "timezone": " Europe/Berlin ", "backfill_days": 14, + "seat_patterns": []any{" */profiles/{seat}/projects/* "}, + }}, + want: FrictionConfig{ + Enabled: true, Timezone: "Europe/Berlin", BackfillDays: 14, + SeatPatterns: []string{"*/profiles/{seat}/projects/*"}, + }, + }, + { + name: "partial table keeps defaults", + data: map[string]any{"friction": map[string]any{"timezone": "UTC"}}, + want: FrictionConfig{Timezone: "UTC", BackfillDays: DefaultFrictionBackfillDays}, + }, + { + name: "explicit false disables", + data: map[string]any{"friction": map[string]any{"enabled": false}}, + want: FrictionConfig{Enabled: false, BackfillDays: DefaultFrictionBackfillDays}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadMinimalWithConfig(t, tt.data) + assert.Equal(t, tt.want, cfg.Friction) + }) + } + + err := loadMinimalErrWithConfig(t, map[string]any{ + "friction": map[string]any{"timezone": "Nowhere/Land"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `[friction] timezone "Nowhere/Land" is not an IANA zone`) +} + func TestFrictionConfigTOMLLoad(t *testing.T) { assert.False(t, loadMinimalWithConfig(t, map[string]any{}).Friction.Enabled, "friction review is opt-in") diff --git a/internal/config/validation.go b/internal/config/validation.go index ece81bc7b3..1470f182af 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -53,6 +53,7 @@ func (c *Config) validateSections() error { {ScopeVector, "[vector]", c.Vector.Validate}, {ScopeRecall, "[recall.extract]", c.Recall.Extract.Validate}, {ScopeInsights, "[insights]", c.Insights.Validate}, + {ScopeServer, "[friction]", c.Friction.Validate}, {ScopePG, "[pg]", func() error { return c.pgConfigErr }}, {ScopeClickHouse, "[clickhouse]", func() error { return c.clickHouseConfigErr }}, } { diff --git a/internal/db/db.go b/internal/db/db.go index 3e3fde7cb2..d92af7b59a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1920,6 +1920,9 @@ var readOnlyRequiredTables = []string{ "model_pricing_bands", "genai_pricing", "secret_findings", + "friction_digests", + "friction_digest_sessions", + "friction_patterns", "recall_entries", "recall_evidence", "recall_query_events", @@ -3814,6 +3817,9 @@ func (db *DB) createPartialIndexesLocked(ctx context.Context, w *writerHandle) e ON sessions(secret_leak_count) WHERE secret_leak_count > 0`, `CREATE INDEX IF NOT EXISTS idx_sessions_friction_rules_version ON sessions(friction_rules_version)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_friction_activity + ON sessions(COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, ''))) + WHERE deleted_at IS NULL`, } for _, ddl := range indexes { if _, err := w.Exec(ctx, ddl); err != nil { diff --git a/internal/db/friction_digest.go b/internal/db/friction_digest.go new file mode 100644 index 0000000000..e27cdaae9a --- /dev/null +++ b/internal/db/friction_digest.go @@ -0,0 +1,544 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log" + "maps" + "slices" + "time" + + "go.kenn.io/agentsview/internal/friction" +) + +// ErrFrictionDigestConflict reports that another builder wrote the digest +// row first (or the expected revision moved). The caller treats it as +// "already built", never as a failure to retry blindly. +var ErrFrictionDigestConflict = errors.New("friction digest was written concurrently") + +// ErrFrictionMembershipConflict means another date claimed a selected subject. +// The complete save rolls back; a new build must select subjects again. +var ErrFrictionMembershipConflict = errors.New("friction subject belongs to another digest date") + +// ErrFrictionSubjectsChanged requires reloading inputs after a concurrent push. +var ErrFrictionSubjectsChanged = errors.New("friction subjects changed during the build") + +// FrictionSubject is one reviewable subject for a digest date. +type FrictionSubject struct { + SubjectID, SubjectKind, Machine, FilePath, Agent string + IsSubAgent bool + AlreadyDigested bool + LastActivity time.Time + RulesVersion string + // SessionVersion is the PostgreSQL row version captured before reading + // findings and usage. SQLite builds use the caller's sync barrier instead. + SessionVersion string +} + +// FrictionDigest is one stored digest row. +type FrictionDigest struct { + Date, Timezone, RulesVersion string + BuiltAt time.Time + Revision, SessionsScanned int + SnapshotJSON, SummaryJSON, Markdown []byte + MarkdownSHA256, RunID string +} + +// FrictionDigestSubject records that a subject was reviewed into a date. +type FrictionDigestSubject struct { + SubjectID, Date, SubjectKind string + SessionVersion string +} + +// FrictionPatternUpdate is one (fingerprint, subject) contribution to local +// recurrence counts for a digest date. +type FrictionPatternUpdate struct { + Fingerprint, Kind, Title, Date, SubjectID string + Ordinal *int + Occurrences int +} + +// FrictionDayBounds returns the first instant of date and the following date. +// A skipped date has an empty interval; a repeated midnight uses its first occurrence. +func FrictionDayBounds(date string, loc *time.Location) (time.Time, time.Time, error) { + day, err := time.Parse("2006-01-02", date) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("parsing friction date %q: %w", date, err) + } + return frictionDateStart(day, loc), frictionDateStart(day.AddDate(0, 0, 1), loc), nil +} + +func frictionDateStart(day time.Time, loc *time.Location) time.Time { + // Search offset intervals in time order so gaps and repeated midnights + // resolve to the earliest valid instant. Two days cover all IANA UTC offsets. + for cursor := day.Add(-48 * time.Hour).In(loc); ; { + _, offset := cursor.Zone() + _, end := cursor.ZoneBounds() + candidate := day.Add(-time.Duration(offset) * time.Second).In(loc) + if candidate.Before(cursor) { + candidate = cursor + } + if end.IsZero() || candidate.Before(end) { + return candidate + } + cursor = end + } +} + +const frictionPendingDatesSQL = ` + SELECT COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, '')) + FROM sessions s + LEFT JOIN friction_digest_sessions ds ON ds.subject_id = s.id + WHERE s.deleted_at IS NULL AND ds.subject_id IS NULL + AND s.friction_rules_version <> ? + AND COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, '')) >= ? + AND COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, '')) < ?` + +// FrictionPendingDates finds unrecorded sessions across the backfill window, +// including dates whose digest already exists. +func (db *DB) FrictionPendingDates(ctx context.Context, first, last string, loc *time.Location) ([]string, error) { + from, _, err := FrictionDayBounds(first, loc) + if err != nil { + return nil, err + } + _, to, err := FrictionDayBounds(last, loc) + if err != nil { + return nil, err + } + release, err := db.acquireBulkRead(ctx) + if err != nil { + return nil, err + } + defer release() + rows, err := db.getReader().QueryContext(ctx, frictionPendingDatesSQL, + friction.SkippedRulesVersion, + from.Add(-24*time.Hour).UTC().Format(time.RFC3339), + to.Add(24*time.Hour).UTC().Format(time.RFC3339)) + if err != nil { + return nil, fmt.Errorf("listing pending friction dates: %w", err) + } + defer rows.Close() + dates := map[string]bool{} + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, err + } + ts, err := parseTimestamp(raw) + if err != nil { + continue + } + date := ts.In(loc).Format("2006-01-02") + if date >= first && date <= last { + dates[date] = true + } + } + return slices.Sorted(maps.Keys(dates)), rows.Err() +} + +func frictionMarkdownSHA(md []byte) string { + sum := sha256.Sum256(md) + return hex.EncodeToString(sum[:]) +} + +const frictionSubjectsSQL = ` + WITH candidates AS ( + SELECT s.id, '' AS digest_date + FROM sessions s + WHERE s.deleted_at IS NULL + AND COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, '')) >= ?2 + AND COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, '')) < ?3 + AND NOT EXISTS (SELECT 1 FROM friction_digest_sessions ds WHERE ds.subject_id = s.id) + UNION ALL + SELECT subject_id, date FROM friction_digest_sessions WHERE ?4 = 1 AND date = ?5 + ) + SELECT s.id, s.machine, COALESCE(s.file_path, ''), s.agent, + CASE WHEN s.parent_session_id IS NOT NULL AND s.relationship_type = 'subagent' THEN 1 ELSE 0 END, + COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), ''), s.friction_rules_version, + c.digest_date + FROM candidates c JOIN sessions s ON s.id = c.id + WHERE s.deleted_at IS NULL AND s.friction_rules_version <> ?1` + +// FrictionSubjectsForDate returns sessions whose last activity falls on the +// local date and that are not yet in any digest; with includeDigested it +// also returns the subjects already recorded for date (rebuild keeps +// membership, spec §8.5). Trashed sessions are never subjects. +func (db *DB) FrictionSubjectsForDate( + ctx context.Context, date string, loc *time.Location, includeDigested bool, +) ([]FrictionSubject, error) { + from, to, err := FrictionDayBounds(date, loc) + if err != nil { + return nil, err + } + labels, err := db.GetMachineLabels(ctx) + if err != nil { + return nil, err + } + // Stored RFC3339 timestamps can retain their source offset. Widen the + // text range by a day, then compare instants after parsing each row. + lo := from.Add(-24 * time.Hour).UTC().Format(time.RFC3339) + hi := to.Add(24 * time.Hour).UTC().Format(time.RFC3339) + rows, err := db.getReader().QueryContext(ctx, frictionSubjectsSQL, + friction.SkippedRulesVersion, lo, hi, boolInt(includeDigested), date, + ) + if err != nil { + return nil, fmt.Errorf("listing friction subjects: %w", err) + } + defer rows.Close() + out := []FrictionSubject{} + for rows.Next() { + var ( + s FrictionSubject + machine, last string + sub int + digestDate string + ) + if err := rows.Scan(&s.SubjectID, &machine, &s.FilePath, &s.Agent, &sub, + &last, &s.RulesVersion, &digestDate); err != nil { + return nil, fmt.Errorf("scanning friction subject: %w", err) + } + if last != "" { + ts, err := parseTimestamp(last) + if err != nil { + log.Printf("friction: session %s has unparseable activity time %q", s.SubjectID, last) + continue + } + s.LastActivity = ts.UTC() + } + if digestDate == "" && (s.LastActivity.IsZero() || + s.LastActivity.Before(from) || !s.LastActivity.Before(to)) { + continue + } + s.SubjectKind = friction.SubjectSession + s.IsSubAgent = sub != 0 + s.AlreadyDigested = digestDate != "" + s.Machine = machine + if label := labels[machine]; label != "" { + s.Machine = label + } + out = append(out, s) + } + return out, rows.Err() +} + +// FrictionFindingsForSubjects returns stored findings for the subjects, +// ordered by (session_id, seq). Callers impose subject order. +func (db *DB) FrictionFindingsForSubjects( + ctx context.Context, subjectIDs []string, +) ([]FrictionFinding, error) { + out := []FrictionFinding{} + err := queryChunked(subjectIDs, func(chunk []string) error { + ph, args := InPlaceholders(chunk) + rows, err := db.getReader().QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id IN `+ph+` + ORDER BY session_id, seq, id`, args...) + if err != nil { + return fmt.Errorf("loading friction findings: %w", err) + } + defer rows.Close() + for rows.Next() { + var ( + f FrictionFinding + ord, call sql.NullInt64 + occurred sql.NullString + ) + if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, + &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, + &occurred, &f.Seq, &f.RulesVersion); err != nil { + return fmt.Errorf("scanning friction finding: %w", err) + } + if ord.Valid { + f.MessageOrdinal = new(int(ord.Int64)) + } + if call.Valid { + f.CallIndex = new(int(call.Int64)) + } + if occurred.Valid && occurred.String != "" { + ts, err := parseTimestamp(occurred.String) + if err == nil { + f.OccurredAt = new(ts.UTC()) + } + } + out = append(out, f) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SaveFrictionDigest writes a digest, its membership and pattern counts in +// one transaction (spec §8.3 step 9). +func (db *DB) SaveFrictionDigest( + ctx context.Context, d FrictionDigest, + subjects []FrictionDigestSubject, patterns []FrictionPatternUpdate, +) error { + if err := db.requireDerivedTextStorage("friction digests"); err != nil { + return err + } + db.mu.Lock() + defer db.mu.Unlock() + tx, err := db.getWriter().BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("beginning friction digest tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + builtAt := d.BuiltAt.UTC().Format(time.RFC3339Nano) + var res sql.Result + if d.Revision <= 1 { + res, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + revision, sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(date) DO NOTHING`, + d.Date, d.Timezone, d.RulesVersion, builtAt, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID) + } else { + res, err = tx.ExecContext(ctx, ` + UPDATE friction_digests SET timezone = ?, rules_version = ?, + built_at = ?, revision = ?, sessions_scanned = ?, + snapshot_json = ?, summary_json = ?, markdown = ?, + markdown_sha256 = ?, run_id = ? + WHERE date = ? AND revision = ?`, + d.Timezone, d.RulesVersion, builtAt, d.Revision, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID, d.Date, d.Revision-1) + } + if err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } else if n == 0 { + return ErrFrictionDigestConflict + } + for _, s := range subjects { + res, err := tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES (?, ?, ?) ON CONFLICT(subject_id) DO NOTHING`, + s.SubjectID, s.Date, s.SubjectKind) + if err != nil { + return fmt.Errorf("recording friction subject %s: %w", s.SubjectID, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("recording friction subject %s: %w", s.SubjectID, err) + } else if n == 0 { + var date string + if err := tx.QueryRowContext(ctx, `SELECT date FROM friction_digest_sessions WHERE subject_id = ?`, s.SubjectID).Scan(&date); err != nil { + return fmt.Errorf("checking friction membership: %w", err) + } + if date != d.Date { + return ErrFrictionMembershipConflict + } + } + } + for _, p := range patterns { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id, last_ordinal) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + kind = excluded.kind, + title = excluded.title, + occurrence_count = friction_patterns.occurrence_count + excluded.occurrence_count, + session_count = friction_patterns.session_count + 1, + first_seen_date = MIN(friction_patterns.first_seen_date, excluded.first_seen_date), + last_subject_id = CASE WHEN excluded.last_seen_date >= friction_patterns.last_seen_date + THEN excluded.last_subject_id ELSE friction_patterns.last_subject_id END, + last_ordinal = CASE WHEN excluded.last_seen_date >= friction_patterns.last_seen_date + THEN excluded.last_ordinal ELSE friction_patterns.last_ordinal END, + last_seen_date = MAX(friction_patterns.last_seen_date, excluded.last_seen_date)`, + p.Fingerprint, p.Kind, p.Title, p.Date, p.Date, p.Occurrences, + p.SubjectID, p.Ordinal); err != nil { + return fmt.Errorf("updating friction pattern %s: %w", p.Fingerprint, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing friction digest %s: %w", d.Date, err) + } + return nil +} + +// GetFrictionDigest returns the digest for date, or nil when none exists. +func (db *DB) GetFrictionDigest(ctx context.Context, date string) (*FrictionDigest, error) { + var ( + d FrictionDigest + builtAt string + snapshot, summary, md string + ) + err := db.getReader().QueryRowContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id + FROM friction_digests WHERE date = ?`, date, + ).Scan(&d.Date, &d.Timezone, &d.RulesVersion, &builtAt, &d.Revision, + &d.SessionsScanned, &snapshot, &summary, &md, &d.MarkdownSHA256, &d.RunID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading friction digest %s: %w", date, err) + } + ts, err := parseTimestamp(builtAt) + if err != nil { + return nil, fmt.Errorf("parsing friction digest built_at %q: %w", builtAt, err) + } + d.BuiltAt = ts.UTC() + d.SnapshotJSON, d.SummaryJSON, d.Markdown = []byte(snapshot), []byte(summary), []byte(md) + return &d, nil +} + +// LatestFrictionDigestDate returns the newest digest date, or "". +func (db *DB) LatestFrictionDigestDate(ctx context.Context) (string, error) { + var date string + if err := db.getReader().QueryRowContext(ctx, + `SELECT COALESCE(MAX(date), '') FROM friction_digests`, + ).Scan(&date); err != nil { + return "", fmt.Errorf("reading latest friction digest date: %w", err) + } + return date, nil +} + +// EarliestSessionDate returns the local date of the earliest session +// activity (nonempty ended_at, else started_at). Unparseable times are ignored. +func (db *DB) EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) { + release, err := db.acquireBulkRead(ctx) + if err != nil { + return "", err + } + defer release() + rows, err := db.getReader().QueryContext(ctx, ` + SELECT COALESCE(NULLIF(ended_at, ''), NULLIF(started_at, '')) + FROM sessions + WHERE deleted_at IS NULL`) + if err != nil { + return "", fmt.Errorf("reading earliest session date: %w", err) + } + defer rows.Close() + // Parse before comparing: source offsets defeat text ordering, and SQLite's + // date functions accept some dates that the archive's RFC3339 parser rejects. + var earliest time.Time + for rows.Next() { + var raw sql.NullString + if err := rows.Scan(&raw); err != nil { + return "", fmt.Errorf("reading session activity: %w", err) + } + if !raw.Valid { + continue + } + ts, err := parseTimestamp(raw.String) + if err == nil && (earliest.IsZero() || ts.Before(earliest)) { + earliest = ts + } + } + if err := rows.Err(); err != nil { + return "", fmt.Errorf("reading session activity: %w", err) + } + if earliest.IsZero() { + return "", nil + } + return earliest.In(loc).Format("2006-01-02"), nil +} + +// UpdateFrictionDigestRender replaces the rendered forms of a digest and +// bumps its revision (spec §8.6). revision is the new revision. +func (db *DB) UpdateFrictionDigestRender( + ctx context.Context, date string, markdown, summaryJSON []byte, revision int, +) error { + if err := db.requireDerivedTextStorage("friction digests"); err != nil { + return err + } + db.mu.Lock() + defer db.mu.Unlock() + res, err := db.getWriter().Exec(ctx, ` + UPDATE friction_digests + SET markdown = ?, summary_json = ?, markdown_sha256 = ?, revision = ? + WHERE date = ? AND revision = ?`, + string(markdown), string(summaryJSON), frictionMarkdownSHA(markdown), + revision, date, revision-1) + if err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } else if n == 0 { + return ErrFrictionDigestConflict + } + return nil +} + +// CopyFrictionStateFrom carries friction review state (digests, membership +// and recurrence) across a full resync, like CopyInsightsFrom. Tables absent +// from an older source archive are skipped. +func (db *DB) CopyFrictionStateFrom(sourcePath string) error { + if err := db.requireDerivedTextStorage("friction review state"); err != nil { + return err + } + db.mu.Lock() + defer db.mu.Unlock() + ctx := context.Background() + conn, err := db.getWriter().Conn(ctx) + if err != nil { + return fmt.Errorf("acquiring connection: %w", err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, "ATTACH DATABASE ? AS old_db", sourcePath); err != nil { + return fmt.Errorf("attaching source db: %w", err) + } + defer func() { _, _ = conn.ExecContext(ctx, "DETACH DATABASE old_db") }() + + copies := []struct{ table, columns string }{ + {"friction_digests", "date, timezone, rules_version, built_at, revision, sessions_scanned, snapshot_json, summary_json, markdown, markdown_sha256, run_id"}, + {"friction_digest_sessions", "subject_id, date, subject_kind"}, + {"friction_patterns", "fingerprint, kind, title, first_seen_date, last_seen_date, occurrence_count, session_count, last_subject_id, last_ordinal"}, + } + for _, c := range copies { + var present int + if err := conn.QueryRowContext(ctx, + `SELECT count(*) FROM old_db.sqlite_master WHERE type = 'table' AND name = ?`, + c.table).Scan(&present); err != nil { + return fmt.Errorf("probing source %s: %w", c.table, err) + } + if present == 0 { + continue + } + if _, err := conn.ExecContext(ctx, + "INSERT OR IGNORE INTO "+c.table+" ("+c.columns+") SELECT "+c.columns+" FROM old_db."+c.table, + ); err != nil { + return fmt.Errorf("copying %s: %w", c.table, err) + } + } + return nil +} + +// FrictionDigestDates lists stored dates without loading their snapshots. +func (db *DB) FrictionDigestDates(ctx context.Context, first, last string) ([]string, error) { + rows, err := db.getReader().QueryContext(ctx, `SELECT date FROM friction_digests WHERE date >= ? AND date <= ? ORDER BY date`, first, last) + if err != nil { + return nil, fmt.Errorf("listing friction digest dates: %w", err) + } + defer rows.Close() + var dates []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + return nil, err + } + dates = append(dates, date) + } + return dates, rows.Err() +} diff --git a/internal/db/friction_digest_test.go b/internal/db/friction_digest_test.go new file mode 100644 index 0000000000..50712ef767 --- /dev/null +++ b/internal/db/friction_digest_test.go @@ -0,0 +1,607 @@ +package db + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/friction" +) + +func TestFrictionPendingDatesSeeksActivityWindow(t *testing.T) { + for _, history := range []int{1, 1024} { + t.Run(strconv.Itoa(history), func(t *testing.T) { + d := testDB(t) + for i := range history { + seedFrictionSession(t, d, "old-"+strconv.Itoa(i), "2000-01-01T12:00:00Z", "", nil) + } + seedFrictionSession(t, d, "recent", "2026-09-15T12:00:00Z", "", nil) + if history > 1 { + // Reopen an archive created before the activity index existed. + _, err := d.getWriter().ExecContext(t.Context(), "DROP INDEX idx_sessions_friction_activity") + require.NoError(t, err) + path := d.Path() + require.NoError(t, d.Close()) + d, err = OpenIsolated(t.Context(), path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, d.Close()) }) + } + dates, err := d.FrictionPendingDates(t.Context(), "2026-09-15", "2026-09-15", time.UTC) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-15"}, dates) + rows, err := d.getReader().QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+frictionPendingDatesSQL, + friction.SkippedRulesVersion, "2026-09-14T00:00:00Z", "2026-09-17T00:00:00Z") + require.NoError(t, err) + defer rows.Close() + plan := strings.Join(explainQueryPlanDetails(t, rows), "\n") + assert.Contains(t, plan, "SEARCH s ") + assert.Contains(t, plan, "(>? AND >? AND to { + continue + } + addFrictionPeriodRow(&week, row) + if row.Date == to { + y := newFrictionPeriod() + addFrictionPeriodRow(&y, row) + yesterday = &y + } + } + if week.Days == 0 { + return nil + } + return &FrictionArchiveSpendSummary{ + Yesterday: yesterday, Week: week, + WeekFrom: from, WeekTo: to, Timezone: timezone, + } +} + +// FrictionUsageFromSessionUsage converts one session's usage into the +// review's stats form. The bool is false when the session carries no +// token or cost data (jilog load_stats → None). +func FrictionUsageFromSessionUsage(u *SessionUsage) (FrictionSessionUsage, bool) { + if u == nil || (!u.HasTokenData && !u.HasCost && len(u.Breakdown) == 0) { + return FrictionSessionUsage{}, false + } + out := FrictionSessionUsage{SubjectID: u.SessionID, ModelCosts: map[string]friction.USD{}} + for _, row := range u.Breakdown { + out.InputTokens += uint64(row.InputTokens + row.CacheReadInputTokens + row.CacheCreationInputTokens) + out.OutputTokens += uint64(row.OutputTokens) + if row.HasCost { + out.ModelCosts[row.Model] = addFrictionUSD(out.ModelCosts, row.Model, row.Cost.Microdollars) + } + } + // Aggregate output survives when model rows are missing or incomplete. + out.OutputTokens = max(out.OutputTokens, uint64(max(u.TotalOutputTokens, 0))) + if u.HasCost { + c := friction.USDFromMicros(u.Cost.Microdollars) + out.CostUSD = &c + } + return out, true +} + +// FrictionUsageSource is the part of db.Store both primary backends share +// for per-session usage. +type FrictionUsageSource interface { + GetSessionUsage(ctx context.Context, sessionID string, includeBreakdown bool) (*SessionUsage, error) + GetDailyUsage(ctx context.Context, f UsageFilter) (DailyUsageResult, error) +} + +// FrictionUsageForSessionsFrom is the shared implementation of +// FrictionUsageForSessions for any backend with GetSessionUsage. +func FrictionUsageForSessionsFrom( + ctx context.Context, src FrictionUsageSource, sessionIDs []string, +) (map[string]FrictionSessionUsage, error) { + out := make(map[string]FrictionSessionUsage, len(sessionIDs)) + for _, id := range sessionIDs { + if err := ctx.Err(); err != nil { + return nil, err + } + u, err := src.GetSessionUsage(ctx, id, true) + if err := ctx.Err(); err != nil { + return nil, err + } + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + log.Printf("friction: session usage unavailable: %v", err) + continue + } + if fu, ok := FrictionUsageFromSessionUsage(u); ok { + fu.SubjectID = id + out[id] = fu + } + } + return out, nil +} + +// FrictionArchiveSpendFrom is the shared implementation of +// FrictionArchiveSpend: native daily usage in the review zone replaces +// jilog's `agentsview usage daily` shell-out (spec §4.1). +func FrictionArchiveSpendFrom( + ctx context.Context, src FrictionUsageSource, from, to string, loc *time.Location, +) (*FrictionArchiveSpendSummary, error) { + res, err := src.GetDailyUsage(ctx, UsageFilter{ + From: from, To: to, Timezone: loc.String(), + Breakdowns: true, SkipSessionCounts: true, + }) + if err != nil { + return nil, fmt.Errorf("friction archive spend %s..%s: %w", from, to, err) + } + return FrictionArchiveSpendFromDaily(res, from, to, loc.String()), nil +} + +// FrictionUsageForSessions implements review.Store. +func (db *DB) FrictionUsageForSessions( + ctx context.Context, sessionIDs []string, +) (map[string]FrictionSessionUsage, error) { + return FrictionUsageForSessionsFrom(ctx, db, sessionIDs) +} + +// FrictionArchiveSpend implements review.Store. +func (db *DB) FrictionArchiveSpend( + ctx context.Context, from, to string, loc *time.Location, +) (*FrictionArchiveSpendSummary, error) { + return FrictionArchiveSpendFrom(ctx, db, from, to, loc) +} diff --git a/internal/db/friction_usage_test.go b/internal/db/friction_usage_test.go new file mode 100644 index 0000000000..52a84c6d4e --- /dev/null +++ b/internal/db/friction_usage_test.go @@ -0,0 +1,229 @@ +package db + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/money" +) + +// Port of jilog archive_spend.rs:270 parses_daily_rows_and_summarizes_yesterday_and_week, +// fed as the native DailyUsageResult instead of `usage daily --json`. +func TestFrictionArchiveSpendFromDaily(t *testing.T) { + m := func(micros int64) money.Money { return money.Money{Microdollars: micros} } + daily := DailyUsageResult{Daily: []DailyUsageEntry{ + { + Date: "2026-09-14", TotalCost: m(1500000), + ModelBreakdowns: []ModelBreakdown{{ModelName: "model-a", Cost: m(1500000)}}, + AgentBreakdowns: []AgentBreakdown{{Agent: "codex", Cost: m(1500000)}}, + }, + { + Date: "2026-09-15", TotalCost: m(6000000), + ModelBreakdowns: []ModelBreakdown{ + {ModelName: "model-b", Cost: m(3000000)}, + {ModelName: "model-c", Cost: m(2000000)}, + {ModelName: "model-a", Cost: m(1000000)}, + }, + AgentBreakdowns: []AgentBreakdown{ + {Agent: "codex", Cost: m(4000000)}, + {Agent: "claude", Cost: m(1500000)}, + {Agent: "cowork", Cost: m(500000)}, + }, + }, + {Date: "2026-09-16", TotalCost: m(999)}, + }} + + t.Run("parses_daily_rows_and_summarizes_yesterday_and_week", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-16") + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-09", "2026-09-15"}, []string{from, to}) + spend := FrictionArchiveSpendFromDaily(daily, from, to, "Asia/Dhaka") + require.NotNil(t, spend) + assert.Equal(t, "Asia/Dhaka", spend.Timezone) + require.NotNil(t, spend.Yesterday, "2026-09-15 present") + assert.Equal(t, "6.000000", spend.Yesterday.Total.String()) + assert.Equal(t, 2, spend.Week.Days, "the 16th is today and excluded") + assert.Equal(t, "7.500000", spend.Week.Total.String()) + assert.Equal(t, "5.500000", spend.Week.Agents["codex"].String()) + assert.Equal(t, "3.000000", spend.Week.Models["model-b"].String()) + }) + t.Run("yesterday_absent", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-18") + require.NoError(t, err) + spend := FrictionArchiveSpendFromDaily(daily, from, to, "UTC") + require.NotNil(t, spend) + assert.Nil(t, spend.Yesterday) + assert.Equal(t, 3, spend.Week.Days) + }) + t.Run("nothing_in_window", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2027-01-01") + require.NoError(t, err) + assert.Nil(t, FrictionArchiveSpendFromDaily(daily, from, to, "UTC")) + assert.Nil(t, FrictionArchiveSpendFromDaily(DailyUsageResult{}, from, to, "UTC")) + }) + t.Run("window_edges", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-16") + require.NoError(t, err) + edge := DailyUsageResult{Daily: []DailyUsageEntry{ + {Date: "2026-09-08", TotalCost: m(1000000)}, + {Date: "2026-09-09", TotalCost: m(1000000)}, + }} + spend := FrictionArchiveSpendFromDaily(edge, from, to, "UTC") + require.NotNil(t, spend) + assert.Equal(t, 1, spend.Week.Days, "2026-09-08 is outside the 7-day window ending 2026-09-15") + }) +} + +func TestFrictionUsageFromSessionUsage(t *testing.T) { + tests := []struct { + name string + in *SessionUsage + ok bool + input uint64 + output uint64 + cost string + models map[string]string + }{ + {name: "nil", in: nil}, + {name: "no token or cost data", in: &SessionUsage{}}, + { + name: "aggregate only", + in: &SessionUsage{HasTokenData: true, TotalOutputTokens: 700}, + ok: true, output: 700, models: map[string]string{}, + }, + { + name: "partial breakdown keeps aggregate output", + in: &SessionUsage{HasTokenData: true, TotalOutputTokens: 700, Breakdown: []SessionUsageBreakdownEntry{ + {Model: "model-a", InputTokens: 100, OutputTokens: 200}, + }}, + ok: true, input: 100, output: 700, models: map[string]string{}, + }, + { + name: "breakdown exceeds aggregate output", + in: &SessionUsage{HasTokenData: true, TotalOutputTokens: 300, Breakdown: []SessionUsageBreakdownEntry{ + {Model: "model-a", OutputTokens: 400}, + {Model: "model-b", OutputTokens: 500}, + }}, + ok: true, output: 900, models: map[string]string{}, + }, + { + name: "breakdown without session summary counters", + in: &SessionUsage{Breakdown: []SessionUsageBreakdownEntry{ + {Model: "unpriced", InputTokens: 12, OutputTokens: 3}, + }}, + ok: true, input: 12, output: 3, models: map[string]string{}, + }, + { + name: "tokens only", + in: &SessionUsage{HasTokenData: true, Breakdown: []SessionUsageBreakdownEntry{ + {Model: "unpriced", InputTokens: 100, CacheReadInputTokens: 20, CacheCreationInputTokens: 5, OutputTokens: 7}, + }}, + ok: true, input: 125, output: 7, models: map[string]string{}, + }, + { + name: "priced rows", + in: &SessionUsage{ + HasTokenData: true, HasCost: true, Cost: money.Money{Microdollars: 1500000}, + Breakdown: []SessionUsageBreakdownEntry{ + {Model: "m1", InputTokens: 10, OutputTokens: 1, Cost: money.Money{Microdollars: 1000000}, HasCost: true}, + {Model: "m1", InputTokens: 10, OutputTokens: 1, Cost: money.Money{Microdollars: 500000}, HasCost: true}, + {Model: "m2", InputTokens: 1, OutputTokens: 1}, + }, + }, + ok: true, input: 21, output: 3, cost: "1.500000", models: map[string]string{"m1": "1.500000"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := FrictionUsageFromSessionUsage(tt.in) + require.Equal(t, tt.ok, ok) + if !ok { + return + } + assert.Equal(t, tt.input, got.InputTokens) + assert.Equal(t, tt.output, got.OutputTokens) + if tt.cost == "" { + assert.Nil(t, got.CostUSD) + } else { + require.NotNil(t, got.CostUSD) + assert.Equal(t, tt.cost, got.CostUSD.String()) + } + models := map[string]string{} + for k, v := range got.ModelCosts { + models[k] = v.String() + } + assert.Equal(t, tt.models, models) + assert.Empty(t, got.Role, "role is the runner's decision") + }) + } +} + +func TestFrictionUsageForSessionsSQLite(t *testing.T) { + d := testDB(t) + seedFrictionSession(t, d, "priced", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "silent", "", "2026-09-15T02:00:00Z", nil) + cost := money.MustParseDollars("1.5") + require.NoError(t, d.ReplaceSessionUsageEvents(t.Context(), "priced", []UsageEvent{{ + Source: "shutdown", Model: "test-model", InputTokens: 800, OutputTokens: 40, + Cost: &cost, CostStatus: "exact", OccurredAt: "2026-09-15T01:00:00Z", DedupKey: "one", + }})) + got, err := d.FrictionUsageForSessions(t.Context(), []string{"priced", "silent", "missing"}) + require.NoError(t, err) + require.Contains(t, got, "priced") + assert.NotContains(t, got, "silent", "no usage data means no stats") + assert.Equal(t, uint64(800), got["priced"].InputTokens) + require.NotNil(t, got["priced"].CostUSD) + assert.Equal(t, "1.500000", got["priced"].CostUSD.String()) +} + +type failingFrictionUsageSource struct { + *DB + failure error + calls []string +} + +func (s *failingFrictionUsageSource) GetSessionUsage(ctx context.Context, id string, breakdown bool) (*SessionUsage, error) { + s.calls = append(s.calls, id) + if id == "failed" { + return nil, s.failure + } + return s.DB.GetSessionUsage(ctx, id, breakdown) +} + +func TestFrictionUsagePreservesOtherSessionsAfterLookupFailure(t *testing.T) { + d := testDB(t) + for _, id := range []string{"before", "after"} { + seedFrictionSession(t, d, id, "", "2026-09-15T12:00:00Z", nil) + require.NoError(t, d.ReplaceSessionUsageEvents(t.Context(), id, []UsageEvent{{ + Source: "shutdown", Model: "test-model", OutputTokens: 40, + OccurredAt: "2026-09-15T12:00:00Z", DedupKey: id, + }})) + } + for _, tc := range []struct { + name string + failure error + wantCalls []string + }{ + {"one lookup fails", errors.New("usage lookup failed"), []string{"before", "failed", "after"}}, + {"cancelled", context.Canceled, []string{"before", "failed"}}, + {"deadline", context.DeadlineExceeded, []string{"before", "failed"}}, + } { + t.Run(tc.name, func(t *testing.T) { + src := &failingFrictionUsageSource{DB: d, failure: tc.failure} + got, err := FrictionUsageForSessionsFrom(t.Context(), src, []string{"before", "failed", "after"}) + assert.Equal(t, tc.wantCalls, src.calls) + if errors.Is(tc.failure, context.Canceled) || errors.Is(tc.failure, context.DeadlineExceeded) { + require.ErrorIs(t, err, tc.failure) + return + } + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, uint64(40), got["before"].OutputTokens) + assert.Equal(t, uint64(40), got["after"].OutputTokens) + }) + } +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 75f6f53a9a..d73f91befb 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -1389,6 +1389,48 @@ CREATE TABLE IF NOT EXISTS friction_findings ( CREATE INDEX IF NOT EXISTS idx_friction_findings_session ON friction_findings(session_id); +-- Friction Log review state is built independently by each store. +-- One digest is kept for each completed local date. +CREATE TABLE IF NOT EXISTS friction_digests ( + date TEXT PRIMARY KEY, + timezone TEXT NOT NULL, + rules_version TEXT NOT NULL, + built_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + sessions_scanned INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + summary_json TEXT NOT NULL, + markdown TEXT NOT NULL, + markdown_sha256 TEXT NOT NULL, + run_id TEXT NOT NULL +); + +-- A subject enters one digest only, even if the session resumes later. +CREATE TABLE IF NOT EXISTS friction_digest_sessions ( + subject_id TEXT PRIMARY KEY, + date TEXT NOT NULL, + subject_kind TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_friction_digest_sessions_date + ON friction_digest_sessions(date); + +-- Local recurrence per fingerprint across digests. +CREATE TABLE IF NOT EXISTS friction_patterns ( + fingerprint TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + first_seen_date TEXT NOT NULL, + last_seen_date TEXT NOT NULL, + occurrence_count INTEGER NOT NULL, + session_count INTEGER NOT NULL, + last_subject_id TEXT NOT NULL, + last_ordinal INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_friction_patterns_last_seen + ON friction_patterns(last_seen_date); + -- Durable normalized-artifact import claims. Artifact kinds evolve -- independently, so each claim retains a separate version gate. CREATE TABLE IF NOT EXISTS artifact_import_queue ( diff --git a/internal/friction/review/catchup_test.go b/internal/friction/review/catchup_test.go new file mode 100644 index 0000000000..dd8b99f957 --- /dev/null +++ b/internal/friction/review/catchup_test.go @@ -0,0 +1,305 @@ +package review_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" +) + +func reportDates(reps []review.Report) []string { + out := []string{} + for _, r := range reps { + out = append(out, r.Date) + } + return out +} + +func TestCatchUp(t *testing.T) { + tests := []struct { + name string + seed func(t *testing.T, d *db.DB, r *review.Runner) + backfill int + want []string + }{ + { + name: "builds_exactly_the_missing_complete_dates_within_backfill", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}, + subject{id: "today", ended: "2026-09-22T09:00:00Z"}) + }, + backfill: 7, + want: []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-18", "2026-09-19", "2026-09-20", "2026-09-21"}, + }, + { + name: "fills_gaps_before_and_after_latest_digest", + seed: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) + _, err := r.BuildDate(t.Context(), "2026-09-18", review.BuildOptions{}) + require.NoError(t, err) + }, + backfill: 7, + want: []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-19", "2026-09-20", "2026-09-21"}, + }, + { + name: "completes_window_even_when_archive_started_recently", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() + seed(t, d, subject{id: "new", ended: "2026-09-20T10:00:00Z"}) + }, + backfill: 7, + want: []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-18", "2026-09-19", "2026-09-20", "2026-09-21"}, + }, + { + name: "empty_archive_builds_nothing", + seed: func(*testing.T, *db.DB, *review.Runner) {}, + backfill: 7, + want: []string{}, + }, + { + name: "never_today", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() + seed(t, d, subject{id: "today", ended: "2026-09-22T09:00:00Z"}) + }, + backfill: 7, + want: []string{}, + }, + { + name: "backfill_of_one_builds_yesterday_only", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) + }, + backfill: 1, + want: []string{"2026-09-21"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := dbtest.OpenTestDB(t) + r := newRunner(d) + r.BackfillDays = tt.backfill + tt.seed(t, d, r) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, tt.want, reportDates(reps)) + for _, rep := range reps { + assert.True(t, rep.Written, rep.Date) + } + again, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Empty(t, again, "a second catch-up is a no-op") + }) + } +} + +func TestCatchUpZoneDecidesToday(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s", ended: "2026-09-21T10:00:00Z"}) + r := newRunner(d) + r.Loc = tokyo + r.BackfillDays = 1 + r.Now = func() time.Time { return time.Date(2026, 9, 21, 16, 0, 0, 0, time.UTC) } // 01:00 on the 22nd in Tokyo + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-21"}, reportDates(reps)) + assert.Equal(t, "Asia/Tokyo", reps[0].Snapshot.Timezone) +} + +func TestStaleSubjects(t *testing.T) { + t.Run("stale_subjects_keep_waiting", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, + subject{id: "ready", ended: "2026-09-21T10:00:00Z", findings: []db.FrictionFinding{correction("ready", "no, use the other file please")}}, + subject{id: "pending", ended: "2026-09-21T11:00:00Z", rules: "friction-v0"}) + r := newRunner(d) // now = 2026-09-22T10:00Z, 10h after the 21st ended + r.BackfillDays = 1 + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err, "a deferred date is not a job failure") + assert.Empty(t, reps) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + assert.Nil(t, got) + + r.Now = func() time.Time { return time.Date(2026, 9, 24, 1, 0, 0, 0, time.UTC) } + r.BackfillDays = 3 + reps, err = r.CatchUp(t.Context()) + require.NoError(t, err) + assert.NotContains(t, reportDates(reps), "2026-09-21") + assert.Nil(t, stored(t, d, "2026-09-21"), "pending review never expires") + }) + t.Run("dry_run_ignores_staleness", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "pending", ended: "2026-09-21T11:00:00Z", rules: "friction-v0"}) + _, err := newRunner(d).BuildDate(t.Context(), "2026-09-21", review.BuildOptions{DryRun: true}) + require.NoError(t, err) + }) +} + +func TestRerender(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + r := newRunner(d) + _, err := r.BuildDate(t.Context(), "2026-09-15", review.BuildOptions{}) + require.NoError(t, err) + before := stored(t, d, "2026-09-15") + + require.NoError(t, r.Rerender(t.Context(), "2026-09-15")) + assert.Equal(t, before.Revision, stored(t, d, "2026-09-15").Revision, "identical bytes do not bump revision") + + // Simulate bytes produced by an earlier renderer, keeping the frozen input. + require.NoError(t, d.UpdateFrictionDigestRender(t.Context(), "2026-09-15", []byte("old renderer"), before.SummaryJSON, before.Revision+1)) + require.NoError(t, r.Rerender(t.Context(), "2026-09-15")) + after := stored(t, d, "2026-09-15") + assert.Equal(t, before.Revision+2, after.Revision) + assert.Equal(t, before.Markdown, after.Markdown, "markdown comes from the frozen snapshot") + assert.Equal(t, before.SummaryJSON, after.SummaryJSON) + assert.Equal(t, before.SnapshotJSON, after.SnapshotJSON) + + require.ErrorIs(t, r.Rerender(t.Context(), "2020-01-01"), review.ErrDigestNotFound) +} + +func TestCatchUpWaitsForReviewBackfill(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "pending", ended: "2026-09-16T12:00:00Z", rules: "friction-old"}) + r := newRunner(d) + _, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Nil(t, stored(t, d, "2026-09-16"), "old dates must wait for review too") + + seed(t, d, subject{id: "pending", ended: "2026-09-16T12:00:00Z", findings: []db.FrictionFinding{correction("pending", "use the existing helper")}}) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + require.Len(t, reps, 1) + assert.Equal(t, "2026-09-16", reps[0].Date) + assert.Contains(t, string(stored(t, d, "2026-09-16").Markdown), "use the existing helper") +} + +func TestCatchUpIncludesLateSessions(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "first", ended: "2026-09-16T12:00:00Z", findings: []db.FrictionFinding{correction("first", "use the existing helper")}}) + r := newRunner(d) + _, err := r.CatchUp(t.Context()) + require.NoError(t, err) + seed(t, d, subject{id: "late", ended: "2026-09-16T13:00:00Z", findings: []db.FrictionFinding{correction("late", "keep the public API")}}) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + require.Len(t, reps, 1) + assert.Equal(t, "2026-09-16", reps[0].Date) + assert.Equal(t, 2, reps[0].Snapshot.SessionsScanned) + got := stored(t, d, "2026-09-16") + require.NotNil(t, got) + assert.Contains(t, string(got.Markdown), "use the existing helper") + assert.Contains(t, string(got.Markdown), "keep the public API") + assert.Equal(t, 2, got.Revision) + again, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Empty(t, again) + assert.Equal(t, 2, stored(t, d, "2026-09-16").Revision) +} + +type countingReviewStore struct { + review.Store + subjectReads int + earliestReads int + pendingReads int + digestDateReads int +} + +func (s *countingReviewStore) FrictionDigestDates(ctx context.Context, first, last string) ([]string, error) { + s.digestDateReads++ + return s.Store.FrictionDigestDates(ctx, first, last) +} + +func (s *countingReviewStore) EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) { + s.earliestReads++ + return s.Store.EarliestSessionDate(ctx, loc) +} + +func (s *countingReviewStore) FrictionPendingDates(ctx context.Context, first, last string, loc *time.Location) ([]string, error) { + s.pendingReads++ + return s.Store.FrictionPendingDates(ctx, first, last, loc) +} + +func (s *countingReviewStore) FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) { + s.subjectReads++ + return s.Store.FrictionSubjectsForDate(ctx, date, loc, includeDigested) +} + +func TestCatchUpOnlyLoadsChangedDates(t *testing.T) { + for _, tc := range []struct{ days, history int }{{1, 1}, {30, 1024}} { + t.Run(fmt.Sprintf("%d_days_%d_old_sessions", tc.days, tc.history), func(t *testing.T) { + d := dbtest.OpenTestDB(t) + r := newRunner(d) + r.BackfillDays = tc.days + for i := range tc.history { + seed(t, d, subject{id: fmt.Sprintf("history-%d", i), ended: "2000-01-01T12:00:00Z"}) + } + for i := 1; i <= tc.days; i++ { + seed(t, d, subject{id: fmt.Sprintf("day-%d", i), ended: reviewNow.AddDate(0, 0, -i).Format(time.RFC3339)}) + } + _, err := r.CatchUp(t.Context()) + require.NoError(t, err) + + // Membership survives reopening the persistent archive. Deleted and + // trashed late arrivals must not make an unchanged day load again. + seed(t, d, subject{id: "deleted", ended: "2026-09-21T11:00:00Z"}, subject{id: "trashed", ended: "2026-09-21T12:00:00Z"}) + require.NoError(t, d.DeleteSession(t.Context(), "deleted")) + require.NoError(t, d.SoftDeleteSession(t.Context(), "trashed")) + path := d.Path() + require.NoError(t, d.Close()) + d = dbtest.OpenTestDBAt(t, path) + counted := &countingReviewStore{Store: d} + r.Store = counted + again, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Empty(t, again) + assert.Zero(t, counted.subjectReads, "unchanged days must not load subjects or their usage") + assert.Zero(t, counted.earliestReads, "persisted digests avoid scanning all historical activity") + assert.Equal(t, 1, counted.pendingReads, "one bounded query finds all pending dates") + assert.Equal(t, 1, counted.digestDateReads, "one bounded query finds all stored dates") + + seed(t, d, subject{id: "late", ended: "2026-09-21T13:00:00Z"}) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-21"}, reportDates(reps)) + assert.Equal(t, 1, counted.subjectReads, "only the date with an unrecorded session needs rebuilding") + assert.Zero(t, counted.earliestReads) + assert.Equal(t, 2, counted.pendingReads) + assert.Equal(t, 2, counted.digestDateReads) + }) + } +} + +func TestCatchUpBuildsDeferredDateAfterBudgetSkip(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "pending", ended: "2026-09-20T12:00:00Z", rules: "friction-old"}, + subject{id: "ready", ended: "2026-09-21T12:00:00Z"}) + r := newRunner(d) + r.BackfillDays = 2 + first, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-21"}, reportDates(first)) + assert.Nil(t, stored(t, d, "2026-09-20")) + seed(t, d, subject{id: "pending", ended: "2026-09-20T12:00:00Z", rules: friction.SkippedRulesVersion}) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-20"}, reportDates(reps)) + got := stored(t, d, "2026-09-20") + require.NotNil(t, got) + assert.Zero(t, got.SessionsScanned) + assert.Equal(t, 1, stored(t, d, "2026-09-21").Revision) +} diff --git a/internal/friction/review/dates.go b/internal/friction/review/dates.go new file mode 100644 index 0000000000..789d0e145e --- /dev/null +++ b/internal/friction/review/dates.go @@ -0,0 +1,23 @@ +// Package review builds dated Friction Log digests from stored findings. +// It ports jilog's run_review (crates/jilog-review/src/digest.rs:208-706) +// onto database state: subjects come from the archive, the processed set is +// friction_digest_sessions, and "nightly" becomes an hourly catch-up. +package review + +import "time" + +const dateLayout = "2006-01-02" + +func localDate(t time.Time, loc *time.Location) string { + return t.In(loc).Format(dateLayout) +} + +// addDays shifts a YYYY-MM-DD date. Inputs are validated at the API edge +// (BuildDate) or produced by localDate, so a parse failure is a bug. +func addDays(date string, n int) string { + d, err := time.Parse(dateLayout, date) + if err != nil { + panic("review: malformed internal date " + date) + } + return d.AddDate(0, 0, n).Format(dateLayout) +} diff --git a/internal/friction/review/fold.go b/internal/friction/review/fold.go new file mode 100644 index 0000000000..fbde386858 --- /dev/null +++ b/internal/friction/review/fold.go @@ -0,0 +1,65 @@ +package review + +import ( + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +// SpendSummary captures costs and tokens without changing the renderer contract. +type SpendSummary struct { + Total *friction.USD + SessionsWithStats, SessionsWithCost int + InputTokens, OutputTokens uint64 + RoleCosts, ModelCosts map[string]friction.USD +} + +type spendFold struct{ spend SpendSummary } + +func newSpendFold() *spendFold { + return &spendFold{spend: SpendSummary{ + RoleCosts: map[string]friction.USD{}, ModelCosts: map[string]friction.USD{}, + }} +} + +func addUSD(m map[string]friction.USD, key string, v friction.USD) { + if cur, ok := m[key]; ok { + m[key] = cur.Add(v) + return + } + m[key] = v +} + +func sumUSD(p *friction.USD, v friction.USD) *friction.USD { + if p == nil { + c := v + return &c + } + s := p.Add(v) + return &s +} + +func (f *spendFold) addUsage(u db.FrictionSessionUsage) { + f.spend.SessionsWithStats++ + f.spend.InputTokens += u.InputTokens + f.spend.OutputTokens += u.OutputTokens + for model, c := range u.ModelCosts { + addUSD(f.spend.ModelCosts, model, c) + } + if u.CostUSD != nil { + f.spend.SessionsWithCost++ + f.spend.Total = sumUSD(f.spend.Total, *u.CostUSD) + role := u.Role + if role == "" { + role = "(root)" + } + addUSD(f.spend.RoleCosts, role, *u.CostUSD) + } +} + +func (f *spendFold) summary() *SpendSummary { + if f.spend.SessionsWithStats == 0 { + return nil + } + s := f.spend + return &s +} diff --git a/internal/friction/review/fold_test.go b/internal/friction/review/fold_test.go new file mode 100644 index 0000000000..6910f66951 --- /dev/null +++ b/internal/friction/review/fold_test.go @@ -0,0 +1,48 @@ +package review + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +func TestSpendFold(t *testing.T) { + cost := func(micros int64) *friction.USD { u := friction.USDFromMicros(micros); return &u } + t.Run("absent_without_stats", func(t *testing.T) { + assert.Nil(t, newSpendFold().summary()) + }) + t.Run("roles_models_and_optional_cost", func(t *testing.T) { + f := newSpendFold() + f.addUsage(db.FrictionSessionUsage{ + Role: "(root)", InputTokens: 100, OutputTokens: 10, + CostUSD: cost(3100000), ModelCosts: map[string]friction.USD{"m1": *cost(3100000)}, + }) + f.addUsage(db.FrictionSessionUsage{ + Role: "subagent", InputTokens: 5, OutputTokens: 1, + CostUSD: cost(1100000), ModelCosts: map[string]friction.USD{"m1": *cost(1100000)}, + }) + f.addUsage(db.FrictionSessionUsage{Role: "(root)", InputTokens: 7, OutputTokens: 2}) + s := f.summary() + require.NotNil(t, s) + assert.Equal(t, 3, s.SessionsWithStats) + assert.Equal(t, 2, s.SessionsWithCost) + assert.Equal(t, uint64(112), s.InputTokens) + assert.Equal(t, uint64(13), s.OutputTokens) + assert.Equal(t, "4.200000", s.Total.String()) + assert.Equal(t, "3.100000", s.RoleCosts["(root)"].String()) + assert.Equal(t, "1.100000", s.RoleCosts["subagent"].String()) + assert.Equal(t, "4.200000", s.ModelCosts["m1"].String()) + }) + t.Run("all_unpriced_has_no_total", func(t *testing.T) { + f := newSpendFold() + f.addUsage(db.FrictionSessionUsage{Role: "(root)", InputTokens: 10, OutputTokens: 5}) + s := f.summary() + require.NotNil(t, s) + assert.Nil(t, s.Total) + assert.Equal(t, 0, s.SessionsWithCost) + }) +} diff --git a/internal/friction/review/job.go b/internal/friction/review/job.go new file mode 100644 index 0000000000..f82422defb --- /dev/null +++ b/internal/friction/review/job.go @@ -0,0 +1,27 @@ +package review + +import ( + "context" + "time" + + "go.kenn.io/agentsview/internal/poller" +) + +// JobName is the poller job that keeps digests caught up (spec §8.1). +const JobName = "friction-review" + +// Job runs CatchUp hourly. exclusive serializes each day with sync and resync on +// SQLite (engine.RunExclusive) or with manual runs on pg serve; nil runs +// the work directly. +func Job(r *Runner, exclusive func(func() error) error) poller.Job { + return poller.Job{ + Name: JobName, + Interval: time.Hour, + Jitter: 5 * time.Minute, + Cooldown: 10 * time.Minute, + RunAtStart: true, + Run: func(ctx context.Context) error { + return r.catchUp(ctx, exclusive, nil) + }, + } +} diff --git a/internal/friction/review/job_test.go b/internal/friction/review/job_test.go new file mode 100644 index 0000000000..c5ebd2afa7 --- /dev/null +++ b/internal/friction/review/job_test.go @@ -0,0 +1,61 @@ +package review_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction/review" +) + +func TestJob(t *testing.T) { + t.Run("exclusive", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s", ended: "2026-09-21T10:00:00Z"}) + r := newRunner(d) + r.BackfillDays = 1 + calls := 0 + job := review.Job(r, func(work func() error) error { calls++; return work() }) + require.NoError(t, job.Run(t.Context())) + assert.Equal(t, 1, calls, "one date is built under the exclusive section") + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + assert.NotNil(t, got) + }) + + t.Run("direct", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s", ended: "2026-09-21T10:00:00Z"}) + direct := review.Job(newRunner(d), nil) + require.NoError(t, direct.Run(t.Context()), "nil exclusive runs the work directly") + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 1, got.SessionsScanned) + }) +} + +func TestJobYieldsBetweenDates(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "first", ended: "2026-09-20T10:00:00Z"}) + released := false + r := newRunner(d) + r.BackfillDays = 2 + job := review.Job(r, func(work func() error) error { + err := work() + if !released { + released = true + require.NotNil(t, stored(t, d, "2026-09-20")) + assert.Nil(t, stored(t, d, "2026-09-21"), "the first day releases the barrier before the second is built") + // A sync between dates supplies a session that the next build must see. + seed(t, d, subject{id: "arrived", ended: "2026-09-21T10:00:00Z"}) + } + return err + }) + require.NoError(t, job.Run(t.Context())) + got := stored(t, d, "2026-09-21") + require.NotNil(t, got) + assert.Equal(t, 1, got.SessionsScanned) +} diff --git a/internal/friction/review/runner.go b/internal/friction/review/runner.go new file mode 100644 index 0000000000..8c1aac1461 --- /dev/null +++ b/internal/friction/review/runner.go @@ -0,0 +1,498 @@ +package review + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "maps" + "slices" + "sort" + "strings" + "time" + "uuid" + + "github.com/cenkalti/backoff/v7" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/timeutil" +) + +// Store is the persistence the review needs (spec §26.5). Both primary +// backends implement it; read-only mirrors do not run reviews. +type Store interface { + ReadOnly() bool + FrictionPendingDates(ctx context.Context, first, last string, loc *time.Location) ([]string, error) + FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) + FrictionFindingsForSubjects(ctx context.Context, subjectIDs []string) ([]db.FrictionFinding, error) + FrictionUsageForSessions(ctx context.Context, sessionIDs []string) (map[string]db.FrictionSessionUsage, error) + FrictionArchiveSpend(ctx context.Context, from, to string, loc *time.Location) (*db.FrictionArchiveSpendSummary, error) + SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, subjects []db.FrictionDigestSubject, patterns []db.FrictionPatternUpdate) error + GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) + LatestFrictionDigestDate(ctx context.Context) (string, error) + FrictionDigestDates(ctx context.Context, first, last string) ([]string, error) + EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) + UpdateFrictionDigestRender(ctx context.Context, date string, markdown, summaryJSON []byte, revision int) error +} + +// DiagnosticSource yields generic diagnostic error signals dated on a local +// date (spec §11.4). Contract: SubjectID is the diagnostic identity, and the +// source never returns an identity already recorded in +// friction_digest_sessions for a different date. +type DiagnosticSource interface { + DiagnosticSignalsForDate(ctx context.Context, date string, loc *time.Location) ([]friction.Signal, error) +} + +// Runner builds digests from stored findings and optional diagnostics. +type Runner struct { + Store Store + Diagnostics DiagnosticSource + Loc *time.Location + Now func() time.Time + BackfillDays int + SeatPatterns []friction.SeatPattern +} + +type BuildOptions struct{ Rebuild, DryRun bool } + +type Report struct { + Date string + Snapshot Snapshot + Written bool +} + +var ( + ErrIncompleteDate = errors.New("friction: date is not complete in the review time zone") + ErrStaleSubjects = errors.New("friction: findings not yet computed for some sessions") +) + +func (r *Runner) loc() *time.Location { + if r.Loc == nil { + return time.UTC + } + return r.Loc +} + +func (r *Runner) now() time.Time { + if r.Now == nil { + return time.Now() + } + return r.Now() +} + +func sortSubjects(subjects []db.FrictionSubject) { + sort.SliceStable(subjects, func(i, j int) bool { + a, b := subjects[i], subjects[j] + if a.Machine != b.Machine { + return a.Machine < b.Machine + } + if a.FilePath != b.FilePath { + return a.FilePath < b.FilePath + } + return a.SubjectID < b.SubjectID + }) +} + +func signalFromFinding(f db.FrictionFinding, s db.FrictionSubject) friction.Signal { + dims := friction.Dims{Agent: s.Agent, Machine: s.Machine} + sig := friction.Signal{ + Kind: friction.Kind(f.Kind), SubjectID: f.SessionID, SubjectKind: s.SubjectKind, Dims: dims, + Detector: f.Detector, Text: f.Text, ToolName: f.ToolName, Label: f.Label, + Evidence: f.Evidence, Ordinal: f.MessageOrdinal, CallIndex: f.CallIndex, Seq: f.Seq, + } + if f.OccurredAt != nil { + sig.OccurredAt = *f.OccurredAt + } + return sig +} + +// patternUpdates emits one update per (fingerprint, subject), skipping +// subjects already counted by an earlier build of the same date. +func patternUpdates(date string, sigs []friction.Signal, skip map[string]bool) []db.FrictionPatternUpdate { + type key struct{ fp, subject string } + idx := map[key]int{} + out := []db.FrictionPatternUpdate{} + for _, s := range sigs { + if skip[s.SubjectID] { + continue + } + k := key{s.Fingerprint(), s.SubjectID} + if i, ok := idx[k]; ok { + out[i].Occurrences++ + if s.Ordinal != nil { + out[i].Ordinal = s.Ordinal + } + continue + } + idx[k] = len(out) + out = append(out, db.FrictionPatternUpdate{ + Fingerprint: k.fp, Kind: string(s.Kind), Title: s.Title(), Date: date, + SubjectID: s.SubjectID, Ordinal: s.Ordinal, Occurrences: 1, + }) + } + return out +} + +// diagnosticSignals returns the date's diagnostic signals ordered by +// identity then Seq, forcing SubjectKind. On a rebuild, identities recorded +// by the earlier build are kept even if the source no longer returns them. +// A source error fails the build (the poller retries): skipping would drop +// those diagnostics from a date that is then never rebuilt. +func (r *Runner) diagnosticSignals( + ctx context.Context, date string, loc *time.Location, prior *Snapshot, +) ([]friction.Signal, error) { + var out []friction.Signal + if r.Diagnostics != nil { + sigs, err := r.Diagnostics.DiagnosticSignalsForDate(ctx, date, loc) + if err != nil { + return nil, fmt.Errorf("friction diagnostics for %s: %w", date, err) + } + out = append(out, sigs...) + } + fresh := map[string]bool{} + for _, s := range out { + fresh[s.SubjectID] = true + } + if prior != nil { + for _, s := range prior.Signals { + if s.SubjectKind == friction.SubjectDiagnostic && !fresh[s.SubjectID] { + out = append(out, s) + } + } + } + for i := range out { + out[i].SubjectKind = friction.SubjectDiagnostic + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].SubjectID != out[j].SubjectID { + return out[i].SubjectID < out[j].SubjectID + } + return out[i].Seq < out[j].Seq + }) + return out, nil +} + +// BuildDate ports run_review for one completed local date (spec §8.3). +func (r *Runner) BuildDate(ctx context.Context, date string, opts BuildOptions) (Report, error) { + // Reload inputs once when a concurrent builder claims membership or a push + // changes a selected session; leave further contention to the next pass. + rep, err := backoff.Retry(ctx, func() (Report, error) { + rep, err := r.buildDate(ctx, date, opts) + if err != nil && !errors.Is(err, db.ErrFrictionMembershipConflict) && !errors.Is(err, db.ErrFrictionSubjectsChanged) { + return rep, backoff.Permanent(err) + } + return rep, err + }, backoff.WithBackOff(&backoff.ZeroBackOff{}), backoff.WithMaxTries(2), backoff.WithMaxElapsedTime(0)) + if err != nil { + return rep, errors.Join(backoff.AsRetryError(err).LastErr, context.Cause(ctx)) + } + return rep, nil +} + +func (r *Runner) buildDate(ctx context.Context, date string, opts BuildOptions) (Report, error) { + loc := r.loc() + if !timeutil.IsValidDate(date) { + return Report{}, fmt.Errorf("friction: invalid date %q (want YYYY-MM-DD)", date) + } + if date >= localDate(r.now(), loc) && !opts.DryRun { + return Report{}, fmt.Errorf("%w: %s", ErrIncompleteDate, date) + } + existing, err := r.Store.GetFrictionDigest(ctx, date) + if err != nil { + return Report{}, err + } + if existing != nil && !opts.Rebuild && !opts.DryRun { + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return Report{}, err + } + return Report{Date: date, Snapshot: snap}, nil + } + + all, err := r.Store.FrictionSubjectsForDate(ctx, date, loc, opts.Rebuild) + if err != nil { + return Report{}, err + } + sortSubjects(all) + subjects := make([]db.FrictionSubject, 0, len(all)) + stale := 0 + for _, s := range all { + switch { + case s.RulesVersion == friction.SkippedRulesVersion: + case r.Store.ReadOnly() && strings.HasSuffix(s.RulesVersion, ":skipped"): + case r.Store.ReadOnly() && s.RulesVersion == "": + // A replica cannot review sessions whose source has review disabled. + case !r.Store.ReadOnly() && s.RulesVersion != friction.RulesVersion: + stale++ + default: + subjects = append(subjects, s) + } + } + if stale > 0 && !opts.DryRun { + return Report{}, fmt.Errorf("%w: %d session(s) on %s", ErrStaleSubjects, stale, date) + } + + ids := make([]string, len(subjects)) + for i, s := range subjects { + ids[i] = s.SubjectID + } + findings, err := r.Store.FrictionFindingsForSubjects(ctx, ids) + if err != nil { + return Report{}, err + } + bySubject := map[string][]db.FrictionFinding{} + for _, f := range findings { + bySubject[f.SessionID] = append(bySubject[f.SessionID], f) + } + usage, err := r.Store.FrictionUsageForSessions(ctx, ids) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return Report{}, err + } + // load_stats failures only warn (digest.rs:471-491). + log.Printf("friction review: %s: usage unavailable: %v", date, err) + usage = nil + } + + fold := newSpendFold() + subAgent := map[string]bool{} + var signals, errorSignals []friction.Signal + for _, s := range subjects { + subAgent[s.SubjectID] = s.IsSubAgent + rows := bySubject[s.SubjectID] + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Seq < rows[j].Seq }) + sigs := make([]friction.Signal, 0, len(rows)) + for _, f := range rows { + sig := signalFromFinding(f, s) + sig.Dims.Seat = friction.SeatFromPath(s.FilePath, r.SeatPatterns) + sigs = append(sigs, sig) + if sig.Kind == friction.KindError { + errorSignals = append(errorSignals, sig) + } + } + if u, ok := usage[s.SubjectID]; ok { + u.Role = "(root)" + if s.IsSubAgent { + u.Role = "subagent" + } + fold.addUsage(u) + } + signals = append(signals, sigs...) + } + + // Diagnostic subjects come last, ordered by identity (spec §8.3 step 1). + // A rebuild keeps the diagnostic members the first build recorded. + var prior *Snapshot + if existing != nil { + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return Report{}, err + } + prior = &snap + } + diagnostics, err := r.diagnosticSignals(ctx, date, loc, prior) + if err != nil { + return Report{}, err + } + diagIDs := []string{} + for _, sig := range diagnostics { + if len(diagIDs) == 0 || diagIDs[len(diagIDs)-1] != sig.SubjectID { + diagIDs = append(diagIDs, sig.SubjectID) + } + } + signals = append(signals, diagnostics...) + + from, to, err := db.FrictionArchiveWindow(date) + if err != nil { + return Report{}, err + } + archive, err := r.Store.FrictionArchiveSpend(ctx, from, to, loc) + if err != nil { + log.Printf("friction review: %s: archive spend hidden: %v", date, err) + archive = nil + } + + snap := Snapshot{ + Date: date, Signals: signals, + P0Alerts: friction.DetectP0Alerts(errorSignals, func(id string) bool { return subAgent[id] }), + SessionsScanned: len(subjects) + len(diagIDs), + Timezone: loc.String(), RulesVersion: friction.RulesVersion, + Spend: fold.summary(), ArchiveSpend: archive, + } + if opts.DryRun { + return Report{Date: date, Snapshot: snap}, nil + } + + md := friction.RenderMarkdown(snap.DigestSnapshot) + summary := friction.RenderSummaryJSON(snap.DigestSnapshot) + snapJSON, err := EncodeSnapshot(snap) + if err != nil { + return Report{}, err + } + runID := uuid.NewV7() + revision := 1 + counted := map[string]bool{} + for _, subject := range subjects { + if subject.AlreadyDigested { + counted[subject.SubjectID] = true + } + } + if existing != nil { + revision = existing.Revision + 1 + } + if prior != nil { + for _, s := range prior.Signals { + if s.SubjectKind == friction.SubjectDiagnostic { + counted[s.SubjectID] = true + } + } + } + sum := sha256.Sum256(md) + digest := db.FrictionDigest{ + Date: date, Timezone: loc.String(), RulesVersion: friction.RulesVersion, + BuiltAt: r.now().UTC(), Revision: revision, SessionsScanned: snap.SessionsScanned, + SnapshotJSON: snapJSON, SummaryJSON: summary, Markdown: md, + MarkdownSHA256: hex.EncodeToString(sum[:]), RunID: runID.String(), + } + members := make([]db.FrictionDigestSubject, 0, len(subjects)+len(diagIDs)) + for _, s := range subjects { + members = append(members, db.FrictionDigestSubject{SubjectID: s.SubjectID, Date: date, SubjectKind: s.SubjectKind, SessionVersion: s.SessionVersion}) + } + for _, id := range diagIDs { + members = append(members, db.FrictionDigestSubject{SubjectID: id, Date: date, SubjectKind: friction.SubjectDiagnostic}) + } + err = r.Store.SaveFrictionDigest(ctx, digest, members, patternUpdates(date, signals, counted)) + if errors.Is(err, db.ErrFrictionDigestConflict) { + log.Printf("friction review: %s: another builder wrote this digest first", date) + return Report{Date: date, Snapshot: snap}, nil + } + if err != nil { + return Report{}, err + } + return Report{Date: date, Snapshot: snap, Written: true}, nil +} + +var ErrDigestNotFound = errors.New("friction: no digest for date") + +func (r *Runner) backfillDays() int { + if r.BackfillDays < 1 { + return 7 + } + return r.BackfillDays +} + +// CatchUp builds missing days and revisits days with unrecorded sessions within +// the backfill window. Pending local reviews defer their date without blocking +// other dates. Each build rechecks subjects under the caller's sync barrier. +func (r *Runner) CatchUp(ctx context.Context) ([]Report, error) { + var reports []Report + err := r.catchUp(ctx, nil, func(rep Report) { reports = append(reports, rep) }) + return reports, err +} + +// catchUp passes each written report to onBuilt. The scheduled job passes nil +// so each snapshot is dropped once its digest is saved; a long backfill does +// not hold every day's findings in memory. +func (r *Runner) catchUp( + ctx context.Context, exclusive func(func() error) error, onBuilt func(Report), +) error { + loc := r.loc() + today := localDate(r.now(), loc) + yesterday := addDays(today, -1) + latest, err := r.Store.LatestFrictionDigestDate(ctx) + if err != nil { + return err + } + start := addDays(today, -r.backfillDays()) + if latest == "" { + // Only the initial pass needs the archive's beginning. Once a digest + // exists, the bounded pending-date query discovers late arrivals. + earliest, err := r.Store.EarliestSessionDate(ctx, loc) + if err != nil { + return err + } + if earliest == "" || earliest > yesterday { + return nil + } + } + if start > yesterday { + return nil + } + pending, err := r.Store.FrictionPendingDates(ctx, start, yesterday, loc) + if err != nil { + return err + } + // Check every date in the bounded window: later digests do not imply + // that earlier dates finished review. Even an empty, budget-skipped day + // must be completed once its pending reviews are resolved. + dates := map[string]bool{} + for date := start; date <= yesterday; date = addDays(date, 1) { + dates[date] = true + } + if latest != "" { + built, err := r.Store.FrictionDigestDates(ctx, start, yesterday) + if err != nil { + return err + } + for _, date := range built { + delete(dates, date) + } + } + for _, date := range pending { + dates[date] = true + } + for _, date := range slices.Sorted(maps.Keys(dates)) { + work := func() error { + if err := ctx.Err(); err != nil { + return err + } + rep, err := r.BuildDate(ctx, date, BuildOptions{Rebuild: true}) + if errors.Is(err, ErrStaleSubjects) { + log.Printf("friction review: deferring %s: %v", date, err) + return nil + } + if err != nil { + return fmt.Errorf("building friction digest %s: %w", date, err) + } + if rep.Written && onBuilt != nil { + onBuilt(rep) + } + return nil + } + if exclusive == nil { + err = work() + } else { + err = exclusive(work) + } + if err != nil { + return err + } + } + return nil +} + +// Rerender rebuilds a digest's Markdown and summary JSON from its frozen +// snapshot and bumps revision when either changed (spec §8.6). +func (r *Runner) Rerender(ctx context.Context, date string) error { + existing, err := r.Store.GetFrictionDigest(ctx, date) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("%w: %s", ErrDigestNotFound, date) + } + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return err + } + md := friction.RenderMarkdown(snap.DigestSnapshot) + summary := friction.RenderSummaryJSON(snap.DigestSnapshot) + if bytes.Equal(md, existing.Markdown) && bytes.Equal(summary, existing.SummaryJSON) { + return nil + } + return r.Store.UpdateFrictionDigestRender(ctx, date, md, summary, existing.Revision+1) +} diff --git a/internal/friction/review/runner_test.go b/internal/friction/review/runner_test.go new file mode 100644 index 0000000000..457f30e45b --- /dev/null +++ b/internal/friction/review/runner_test.go @@ -0,0 +1,487 @@ +package review_test + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/money" +) + +var reviewNow = time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + +type subject struct { + id, ended, machine, agent, path string + parent string + findings []db.FrictionFinding + rules string + usage []db.UsageEvent +} + +func seed(t *testing.T, d *db.DB, subjects ...subject) { + t.Helper() + for _, s := range subjects { + ended := s.ended + machine, agent := s.machine, s.agent + if machine == "" { + machine = "machine-a" + } + if agent == "" { + agent = "claude" + } + dbtest.SeedSession(t, d, s.id, "proj", func(sess *db.Session) { + sess.EndedAt = &ended + sess.Machine = machine + sess.Agent = agent + if s.path != "" { + sess.FilePath = &s.path + } + if s.parent != "" { + sess.ParentSessionID = dbtest.Ptr(s.parent) + sess.RelationshipType = "subagent" + } + }) + rules := s.rules + if rules == "" { + rules = friction.RulesVersion + } + require.NoError(t, d.UpdateSessionSignals(t.Context(), s.id, db.SessionSignalUpdate{Friction: &db.SessionFrictionUpdate{Findings: s.findings, RulesVersion: rules, Hash: "h-" + s.id}})) + if len(s.usage) > 0 { + require.NoError(t, d.ReplaceSessionUsageEvents(t.Context(), s.id, s.usage)) + } + } +} + +func finding(sid string, kind friction.Kind, detector, text, label, tool string, ord, seq int) db.FrictionFinding { + sig := friction.Signal{ + Kind: kind, SubjectID: sid, Detector: detector, Text: text, + Label: label, ToolName: tool, Ordinal: dbtest.Ptr(ord), Seq: seq, + } + return db.FrictionFinding{ + SessionID: sid, Kind: string(kind), Detector: detector, + MessageOrdinal: dbtest.Ptr(ord), ToolName: tool, Label: label, Text: text, + Title: sig.Title(), Fingerprint: sig.Fingerprint(), Seq: seq, RulesVersion: friction.RulesVersion, + } +} + +func correction(sid, text string) db.FrictionFinding { + return finding(sid, friction.KindCorrection, "correction.coding", text, "", "", 1, 0) +} + +func tokens(in, out int, cost string) []db.UsageEvent { + e := db.UsageEvent{ + Source: "shutdown", Model: "unpriced-test-model", InputTokens: in, + OutputTokens: out, OccurredAt: "2026-09-15T01:00:00Z", DedupKey: "k", + } + if cost != "" { + c := money.MustParseDollars(cost) + e.Cost, e.CostStatus = &c, "exact" + } + return []db.UsageEvent{e} +} + +func newRunner(d *db.DB) *review.Runner { + return &review.Runner{Store: d, Loc: time.UTC, Now: func() time.Time { return reviewNow }, BackfillDays: 7} +} + +func rowCount(t *testing.T, d *db.DB, table string) int { + t.Helper() + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + var n int + require.NoError(t, raw.QueryRowContext(t.Context(), "SELECT count(*) FROM "+table).Scan(&n)) + return n +} + +func stored(t *testing.T, d *db.DB, date string) *db.FrictionDigest { + t.Helper() + got, err := d.GetFrictionDigest(t.Context(), date) + require.NoError(t, err) + return got +} + +func TestBuildDate(t *testing.T) { + const day = "2026-09-15" + tests := []struct { + name string + run func(t *testing.T, d *db.DB, r *review.Runner) + }{ + {name: "run_review_writes_digest_when_file_absent_even_with_no_sessions", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Equal(t, 0, rep.Snapshot.SessionsScanned) + got := stored(t, d, day) + require.NotNil(t, got, "first build of a date writes even an empty digest") + assert.Contains(t, string(got.Markdown), "signals_captured: 0") + assert.True(t, strings.HasSuffix(string(got.Markdown), "## Interruptions\n\n_No interruptions detected._\n"), + "the empty digest uses the current renderer") + assert.Equal(t, 1, got.Revision) + assert.NotEmpty(t, got.RunID) + }}, + {name: "run_review_preserves_existing_digest_when_nothing_scanned", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seeded := db.FrictionDigest{ + Date: day, Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: reviewNow, Revision: 1, SnapshotJSON: []byte(`{"date":"` + day + `"}`), + SummaryJSON: []byte("{}\n"), Markdown: []byte("SEEDED-FROM-EARLIER-RUN\n"), + MarkdownSHA256: "x", RunID: "r", + } + require.NoError(t, d.SaveFrictionDigest(t.Context(), seeded, nil, nil)) + seed(t, d, subject{id: "late", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("late", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.False(t, rep.Written, "a plain build of an existing date is a no-op") + assert.Equal(t, "SEEDED-FROM-EARLIER-RUN\n", string(stored(t, d, day).Markdown)) + }}, + {name: "processed_sessions_persist_across_load", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, rep.Snapshot.SessionsScanned) + // The session is resumed; its last activity moves to the next date. + seed(t, d, subject{id: "s1", ended: "2026-09-16T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + next, err := r.BuildDate(t.Context(), "2026-09-16", review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, next.Snapshot.SessionsScanned, "a subject enters one digest only") + }}, + {name: "rebuild_keeps_membership", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "a", ended: "2026-09-15T10:00:00Z", findings: []db.FrictionFinding{correction("a", "no, use the other file please")}}) + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + seed(t, d, + subject{id: "a", ended: "2026-09-17T10:00:00Z", findings: []db.FrictionFinding{correction("a", "no, use the other file please")}}, + subject{id: "b", ended: "2026-09-15T11:00:00Z", findings: []db.FrictionFinding{correction("b", "that is wrong, revert it now")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Equal(t, 2, rep.Snapshot.SessionsScanned) + got := stored(t, d, day) + assert.Equal(t, 2, got.Revision) + assert.Contains(t, string(got.Markdown), "`a`") + assert.Contains(t, string(got.Markdown), "`b`") + // Pattern counts were not double-counted for the kept member. + var occ int + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + fp := correction("a", "no, use the other file please").Fingerprint + require.NoError(t, raw.QueryRowContext(t.Context(), + `SELECT occurrence_count FROM friction_patterns WHERE fingerprint = ?`, fp).Scan(&occ)) + assert.Equal(t, 1, occ) + }}, + {name: "rebuild_does_not_count_new_findings_on_old_member", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "quiet", ended: "2026-09-15T10:00:00Z"}) + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns")) + require.NoError(t, d.UpdateSessionSignals(t.Context(), "quiet", db.SessionSignalUpdate{Friction: &db.SessionFrictionUpdate{Findings: []db.FrictionFinding{correction("quiet", "no, use the other file please")}, RulesVersion: friction.RulesVersion, Hash: "h-quiet-new"}})) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + require.Len(t, rep.Snapshot.Signals, 1) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "old member's new findings never change recurrence") + }}, + {name: "dry_run_writes_nothing", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{DryRun: true}) + require.NoError(t, err) + assert.False(t, rep.Written) + assert.Len(t, rep.Snapshot.Signals, 1) + assert.Nil(t, stored(t, d, day)) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns")) + built, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, built.Snapshot.SessionsScanned, "dry run did not consume the session") + }}, + {name: "stats_only_session_counts_toward_spend", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{ + id: "sess-counters", ended: "2026-09-15T01:00:00Z", + usage: tokens(500, 25, ""), + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, rep.Snapshot.SessionsScanned) + require.NotNil(t, rep.Snapshot.Spend) + assert.Equal(t, uint64(500), rep.Snapshot.Spend.InputTokens) + }}, + {name: "agent_and_machine_tags_are_stamped_and_rendered", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + const id = "a37ffc87-2799-4a09-830b-a92fde71d768" + seed(t, d, subject{ + id: id, ended: "2026-09-15T01:00:00Z", machine: "laptop-01", + findings: []db.FrictionFinding{correction(id, "no, use the other path")}, + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.Len(t, rep.Snapshot.Signals, 1) + assert.Equal(t, "claude", rep.Snapshot.Signals[0].Dims.Agent) + assert.Equal(t, "laptop-01", rep.Snapshot.Signals[0].Dims.Machine) + assert.Contains(t, string(stored(t, d, day).Markdown), + "- `agent:claude` `machine:laptop-01` `"+id+"` — `no, use the other path`") + }}, + {name: "configured_seat_labels_digest_findings", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{ + id: "seat-session", ended: "2026-09-15T01:00:00Z", + path: "archive/profiles/seat-a/projects/session.jsonl", + findings: []db.FrictionFinding{correction("seat-session", "no, use the other file please")}, + }) + patterns, err := friction.CompileSeatPatterns([]string{"*/profiles/{seat}/projects/*"}) + require.NoError(t, err) + r.SeatPatterns = patterns + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.Len(t, rep.Snapshot.Signals, 1) + assert.Equal(t, "seat-a", rep.Snapshot.Signals[0].Dims.Seat) + assert.Contains(t, string(stored(t, d, day).Markdown), "`seat:seat-a`") + }}, + {name: "subagent_cost_lands_under_subagent_role", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, + subject{id: "root", ended: "2026-09-15T01:00:00Z", usage: tokens(10, 1, "3.1")}, + subject{id: "child", ended: "2026-09-15T02:00:00Z", parent: "root", usage: tokens(10, 1, "1.1")}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.NotNil(t, rep.Snapshot.Spend) + assert.Equal(t, "3.100000", rep.Snapshot.Spend.RoleCosts["(root)"].String()) + assert.Equal(t, "1.100000", rep.Snapshot.Spend.RoleCosts["subagent"].String()) + }}, + {name: "p0_alert_needs_three_top_level_subjects", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + errFinding := func(sid string) db.FrictionFinding { + return finding(sid, friction.KindError, "error", "exit 2: boom", "", "Bash", 2, 0) + } + seed(t, d, + subject{id: "e1", ended: "2026-09-15T01:00:00Z", findings: []db.FrictionFinding{errFinding("e1")}}, + subject{id: "e2", ended: "2026-09-15T02:00:00Z", findings: []db.FrictionFinding{errFinding("e2")}}, + subject{id: "e3", ended: "2026-09-15T03:00:00Z", parent: "e1", findings: []db.FrictionFinding{errFinding("e3")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Empty(t, rep.Snapshot.P0Alerts, "sub-agent subjects never count toward P0") + }}, + {name: "budget_skipped_sessions_are_not_recorded", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{ + id: "x", ended: "2026-09-15T01:00:00Z", + rules: friction.SkippedRulesVersion, + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, rep.Snapshot.SessionsScanned) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + }}, + {name: "stale_rules_wait_regardless_of_age", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "stale", ended: "2026-09-15T01:00:00Z", rules: "friction-old"}) + r.Now = func() time.Time { return time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC) } + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.ErrorIs(t, err, review.ErrStaleSubjects) + assert.Nil(t, stored(t, d, day)) + r.Now = func() time.Time { return time.Date(2026, 9, 18, 0, 1, 0, 0, time.UTC) } + _, err = r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.ErrorIs(t, err, review.ErrStaleSubjects) + assert.Nil(t, stored(t, d, day)) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + }}, + {name: "today_is_refused_unless_dry_run", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + _, err := r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{}) + require.ErrorIs(t, err, review.ErrIncompleteDate) + _, err = r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{DryRun: true}) + require.NoError(t, err) + _, err = r.BuildDate(t.Context(), "2026-9-1", review.BuildOptions{}) + require.Error(t, err) + }}, + {name: "frustration_and_interruption_kinds_flow_through", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{ + correction("s1", "no, use the other file please"), + finding("s1", friction.KindFrustration, "frustration", "this is still broken", "", "", 3, 1), + finding("s1", friction.KindInterruption, "interruption", "", "", "", 4, 2), + }}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + kinds := []friction.Kind{} + for _, s := range rep.Snapshot.Signals { + kinds = append(kinds, s.Kind) + assert.Equal(t, friction.SubjectSession, s.SubjectKind) + } + assert.Equal(t, []friction.Kind{friction.KindCorrection, friction.KindFrustration, friction.KindInterruption}, kinds) + got := stored(t, d, day) + md, summary := string(got.Markdown), string(got.SummaryJSON) + assert.Contains(t, md, "frustrations: 1") + assert.Contains(t, md, "interruptions: 1") + assert.Contains(t, md, "signals_captured: 3") + assert.Contains(t, md, "## Frustration") + assert.Contains(t, md, "- `agent:claude` `machine:machine-a` `s1` interruptions=1") + assert.Contains(t, summary, `"frustrations": 1`) + assert.Contains(t, summary, `"interruptions": 1`) + assert.Contains(t, summary, `"schema_version": 1`) + assert.Equal(t, 3, rowCount(t, d, "friction_patterns"), "every kind feeds local recurrence") + }}, + {name: "diagnostics_come_last_are_recorded_and_never_raise_p0", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + diag := func(id string) friction.Signal { + return friction.Signal{ + Kind: friction.KindError, SubjectID: id, Detector: "error", + ToolName: "nightly_check", Text: id + ": check failed", Dims: friction.Dims{Machine: "ci-runner"}, + } + } + r.Diagnostics = fakeDiagnostics{sigs: []friction.Signal{diag("run-9:nightly_check"), diag("run-1:nightly_check"), diag("run-5:nightly_check")}} + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + ids := []string{} + for _, s := range rep.Snapshot.Signals { + ids = append(ids, s.SubjectID) + } + assert.Equal(t, []string{"s1", "run-1:nightly_check", "run-5:nightly_check", "run-9:nightly_check"}, ids) + assert.Equal(t, friction.SubjectDiagnostic, rep.Snapshot.Signals[1].SubjectKind) + assert.Empty(t, rep.Snapshot.P0Alerts, "diagnostics never raise P0 (spec §6.5)") + assert.Equal(t, 4, rep.Snapshot.SessionsScanned) + assert.Equal(t, 4, stored(t, d, day).SessionsScanned) + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + var n int + require.NoError(t, raw.QueryRowContext(t.Context(), + `SELECT count(*) FROM friction_digest_sessions WHERE subject_kind = 'diagnostic'`).Scan(&n)) + assert.Equal(t, 3, n) + + r.Diagnostics = fakeDiagnostics{} + rebuilt, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + assert.Len(t, rebuilt.Snapshot.Signals, 4, "rebuild keeps diagnostic members") + }}, + {name: "diagnostic_source_error_fails_the_build", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + r.Diagnostics = fakeDiagnostics{err: assert.AnError} + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.ErrorIs(t, err, assert.AnError) + assert.Nil(t, stored(t, d, day), "no partial digest") + }}, + {name: "concurrent_builder_loses_cleanly", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + racing := &raceStore{Store: d, beforeSave: func() { + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: day, Timezone: "UTC", + RulesVersion: friction.RulesVersion, BuiltAt: reviewNow, Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), Markdown: []byte("OTHER\n"), + MarkdownSHA256: "x", RunID: "other", + }, nil, nil)) + }} + r.Store = racing + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.False(t, rep.Written) + assert.Equal(t, "OTHER\n", string(stored(t, d, day).Markdown)) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "losing save rolled back") + }}, + {name: "archive_spend_failure_hides_block", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() + r.Store = &raceStore{Store: d, archiveErr: assert.AnError} + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Nil(t, rep.Snapshot.ArchiveSpend) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := dbtest.OpenTestDB(t) + tt.run(t, d, newRunner(d)) + }) + } +} + +// raceStore wraps a real store to inject a competing writer or a failing +// archive-spend query. +type raceStore struct { + review.Store + beforeSave func() + archiveErr error +} + +func (s *raceStore) SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, subj []db.FrictionDigestSubject, p []db.FrictionPatternUpdate) error { + if s.beforeSave != nil { + s.beforeSave() + } + return s.Store.SaveFrictionDigest(ctx, d, subj, p) +} + +func (s *raceStore) FrictionArchiveSpend(ctx context.Context, from, to string, loc *time.Location) (*db.FrictionArchiveSpendSummary, error) { + if s.archiveErr != nil { + return nil, s.archiveErr + } + return s.Store.FrictionArchiveSpend(ctx, from, to, loc) +} + +type fakeDiagnostics struct { + sigs []friction.Signal + err error +} + +func (f fakeDiagnostics) DiagnosticSignalsForDate(context.Context, string, *time.Location) ([]friction.Signal, error) { + return f.sigs, f.err +} + +func TestSignalsKeepSubjectOrderThenSeq(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, + subject{id: "b-session", ended: "2026-09-15T01:00:00Z", machine: "m1", findings: []db.FrictionFinding{ + finding("b-session", friction.KindWorkaround, "workaround", "hack two", "hack", "", 5, 1), + finding("b-session", friction.KindWorkaround, "workaround", "hack one", "hack", "", 2, 0), + }}, + subject{id: "a-session", ended: "2026-09-15T02:00:00Z", machine: "m2", findings: []db.FrictionFinding{ + finding("a-session", friction.KindWorkaround, "workaround", "for now", "for now", "", 1, 0), + }}) + rep, err := newRunner(d).BuildDate(t.Context(), "2026-09-15", review.BuildOptions{}) + require.NoError(t, err) + got := []string{} + for _, s := range rep.Snapshot.Signals { + got = append(got, s.Text) + } + assert.Equal(t, []string{"hack one", "hack two", "for now"}, got, "(machine, file_path, id) then seq") + assert.True(t, strings.HasPrefix(string(stored(t, d, "2026-09-15").Markdown), "---\n")) +} + +type interruptedUsageStore struct { + review.Store + err error +} + +func (s interruptedUsageStore) FrictionUsageForSessions(context.Context, []string) (map[string]db.FrictionSessionUsage, error) { + return nil, s.err +} + +func TestBuildDateDoesNotSaveAfterUsageCancellation(t *testing.T) { + for _, err := range []error{context.Canceled, context.DeadlineExceeded} { + t.Run(err.Error(), func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "session", ended: "2026-09-15T12:00:00Z"}) + r := newRunner(d) + r.Store = interruptedUsageStore{Store: d, err: err} + _, gotErr := r.BuildDate(t.Context(), "2026-09-15", review.BuildOptions{}) + require.ErrorIs(t, gotErr, err) + assert.Nil(t, stored(t, d, "2026-09-15")) + }) + } +} diff --git a/internal/friction/review/snapshot.go b/internal/friction/review/snapshot.go new file mode 100644 index 0000000000..8de699bfe3 --- /dev/null +++ b/internal/friction/review/snapshot.go @@ -0,0 +1,238 @@ +package review + +import ( + "encoding/json/v2" + "fmt" + "time" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +// Snapshot freezes the renderer input and usage at build time. +type Snapshot struct { + friction.DigestSnapshot + Timezone, RulesVersion string + Spend *SpendSummary + ArchiveSpend *db.FrictionArchiveSpendSummary +} + +// snapshot_json is the canonical frozen input of a digest (spec §5.4). The +// wire form stores USD as decimal strings so scale survives the round trip. +type snapshotWire struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + RulesVersion string `json:"rules_version"` + Signals []signalWire `json:"signals"` + P0Alerts map[string][]string `json:"p0_alerts"` + Spend *spendWire `json:"spend"` + ArchiveSpend *archiveWire `json:"archive_spend"` + SessionsScanned int `json:"sessions_scanned"` +} + +type signalWire struct { + Kind string `json:"kind"` + SubjectID string `json:"subject_id"` + SubjectKind string `json:"subject_kind"` + Seat string `json:"seat"` + Agent string `json:"agent"` + Machine string `json:"machine"` + Persona string `json:"persona"` + Channel string `json:"channel"` + Detector string `json:"detector"` + Text string `json:"text"` + ToolName string `json:"tool_name"` + Label string `json:"label"` + Evidence string `json:"evidence"` + Ordinal *int `json:"ordinal"` + CallIndex *int `json:"call_index"` + OccurredAt string `json:"occurred_at"` + Seq int `json:"seq"` +} + +type spendWire struct { + Total *string `json:"total"` + SessionsWithStats int `json:"sessions_with_stats"` + SessionsWithCost int `json:"sessions_with_cost"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` + RoleCosts map[string]string `json:"role_costs"` + ModelCosts map[string]string `json:"model_costs"` +} + +type periodWire struct { + Total string `json:"total"` + Days int `json:"days"` + Agents map[string]string `json:"agents"` + Models map[string]string `json:"models"` +} + +type archiveWire struct { + Yesterday *periodWire `json:"yesterday"` + Week periodWire `json:"week"` + WeekFrom string `json:"week_from"` + WeekTo string `json:"week_to"` + Timezone string `json:"timezone"` +} + +func usdStrings(m map[string]friction.USD) map[string]string { + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v.String() + } + return out +} + +func parseUSDMap(m map[string]string) (map[string]friction.USD, error) { + out := make(map[string]friction.USD, len(m)) + for k, v := range m { + u, err := friction.ParseUSD(v) + if err != nil { + return nil, fmt.Errorf("snapshot usd %q: %w", k, err) + } + out[k] = u + } + return out, nil +} + +func usdPtrString(u *friction.USD) *string { + if u == nil { + return nil + } + s := u.String() + return &s +} + +func parseUSDPtr(s *string) (*friction.USD, error) { + if s == nil { + return nil, nil + } + u, err := friction.ParseUSD(*s) + if err != nil { + return nil, err + } + return &u, nil +} + +func periodToWire(p db.FrictionPeriodSpend) periodWire { + return periodWire{Total: p.Total.String(), Days: p.Days, Agents: usdStrings(p.Agents), Models: usdStrings(p.Models)} +} + +func periodFromWire(w periodWire) (db.FrictionPeriodSpend, error) { + total, err := friction.ParseUSD(w.Total) + if err != nil { + return db.FrictionPeriodSpend{}, err + } + agents, err := parseUSDMap(w.Agents) + if err != nil { + return db.FrictionPeriodSpend{}, err + } + models, err := parseUSDMap(w.Models) + if err != nil { + return db.FrictionPeriodSpend{}, err + } + return db.FrictionPeriodSpend{Total: total, Days: w.Days, Agents: agents, Models: models}, nil +} + +func EncodeSnapshot(s Snapshot) ([]byte, error) { + w := snapshotWire{ + Date: s.Date, Timezone: s.Timezone, RulesVersion: s.RulesVersion, + Signals: make([]signalWire, 0, len(s.Signals)), P0Alerts: s.P0Alerts, SessionsScanned: s.SessionsScanned, + } + for _, sig := range s.Signals { + sw := signalWire{ + Kind: string(sig.Kind), SubjectID: sig.SubjectID, SubjectKind: sig.SubjectKind, + Seat: sig.Dims.Seat, + Agent: sig.Dims.Agent, Machine: sig.Dims.Machine, Persona: sig.Dims.Persona, + Channel: sig.Dims.Channel, Detector: sig.Detector, Text: sig.Text, + ToolName: sig.ToolName, Label: sig.Label, Evidence: sig.Evidence, + Ordinal: sig.Ordinal, CallIndex: sig.CallIndex, Seq: sig.Seq, + } + if !sig.OccurredAt.IsZero() { + sw.OccurredAt = sig.OccurredAt.UTC().Format(time.RFC3339Nano) + } + w.Signals = append(w.Signals, sw) + } + if s.Spend != nil { + w.Spend = &spendWire{ + Total: usdPtrString(s.Spend.Total), SessionsWithStats: s.Spend.SessionsWithStats, + SessionsWithCost: s.Spend.SessionsWithCost, InputTokens: s.Spend.InputTokens, + OutputTokens: s.Spend.OutputTokens, RoleCosts: usdStrings(s.Spend.RoleCosts), + ModelCosts: usdStrings(s.Spend.ModelCosts), + } + } + if a := s.ArchiveSpend; a != nil { + aw := &archiveWire{Week: periodToWire(a.Week), WeekFrom: a.WeekFrom, WeekTo: a.WeekTo, Timezone: a.Timezone} + if a.Yesterday != nil { + y := periodToWire(*a.Yesterday) + aw.Yesterday = &y + } + w.ArchiveSpend = aw + } + b, err := json.Marshal(w, json.Deterministic(true)) + if err != nil { + return nil, fmt.Errorf("encoding friction snapshot: %w", err) + } + return b, nil +} + +func DecodeSnapshot(b []byte) (Snapshot, error) { + var w snapshotWire + if err := json.Unmarshal(b, &w); err != nil { + return Snapshot{}, fmt.Errorf("decoding friction snapshot: %w", err) + } + s := Snapshot{ + Date: w.Date, P0Alerts: w.P0Alerts, SessionsScanned: w.SessionsScanned, Timezone: w.Timezone, RulesVersion: w.RulesVersion, + } + for _, sw := range w.Signals { + sig := friction.Signal{ + Kind: friction.Kind(sw.Kind), SubjectID: sw.SubjectID, SubjectKind: sw.SubjectKind, + Dims: friction.Dims{Seat: sw.Seat, Agent: sw.Agent, Machine: sw.Machine, Persona: sw.Persona, Channel: sw.Channel}, + Detector: sw.Detector, Text: sw.Text, ToolName: sw.ToolName, Label: sw.Label, + Evidence: sw.Evidence, Ordinal: sw.Ordinal, CallIndex: sw.CallIndex, Seq: sw.Seq, + } + if sw.OccurredAt != "" { + ts, err := time.Parse(time.RFC3339Nano, sw.OccurredAt) + if err != nil { + return Snapshot{}, fmt.Errorf("snapshot occurred_at: %w", err) + } + sig.OccurredAt = ts.UTC() + } + s.Signals = append(s.Signals, sig) + } + if sw := w.Spend; sw != nil { + total, err := parseUSDPtr(sw.Total) + if err != nil { + return Snapshot{}, err + } + roles, err := parseUSDMap(sw.RoleCosts) + if err != nil { + return Snapshot{}, err + } + models, err := parseUSDMap(sw.ModelCosts) + if err != nil { + return Snapshot{}, err + } + s.Spend = &SpendSummary{ + Total: total, SessionsWithStats: sw.SessionsWithStats, + SessionsWithCost: sw.SessionsWithCost, InputTokens: sw.InputTokens, + OutputTokens: sw.OutputTokens, RoleCosts: roles, ModelCosts: models, + } + } + if aw := w.ArchiveSpend; aw != nil { + week, err := periodFromWire(aw.Week) + if err != nil { + return Snapshot{}, err + } + a := &db.FrictionArchiveSpendSummary{Week: week, WeekFrom: aw.WeekFrom, WeekTo: aw.WeekTo, Timezone: aw.Timezone} + if aw.Yesterday != nil { + y, err := periodFromWire(*aw.Yesterday) + if err != nil { + return Snapshot{}, err + } + a.Yesterday = &y + } + s.ArchiveSpend = a + } + return s, nil +} diff --git a/internal/friction/review/snapshot_test.go b/internal/friction/review/snapshot_test.go new file mode 100644 index 0000000000..0fa80b421d --- /dev/null +++ b/internal/friction/review/snapshot_test.go @@ -0,0 +1,79 @@ +package review + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +func sampleSnapshot(t *testing.T) Snapshot { + t.Helper() + cost := friction.USDFromMicros(1500000) + total, err := friction.ParseUSD("4.2") + require.NoError(t, err) + ord := 3 + return Snapshot{ + Timezone: "Asia/Tokyo", RulesVersion: "friction-v1", + Date: "2026-09-15", SessionsScanned: 3, + Signals: []friction.Signal{ + { + Kind: friction.KindCorrection, SubjectID: "s1", Detector: "correction.chat", + Dims: friction.Dims{Agent: "claude", Machine: "laptop-01", Persona: "helper", Channel: "general"}, + Text: "no, don't do that", Ordinal: &ord, Seq: 0, SubjectKind: friction.SubjectSession, + OccurredAt: time.Date(2026, 9, 15, 1, 2, 3, 400000000, time.UTC), + }, + { + Kind: friction.KindError, SubjectID: "s2", Detector: "error", ToolName: "Bash", + Text: "exit 1", CallIndex: &ord, Seq: 1, Dims: friction.Dims{Seat: "seat-02"}, + }, + { + Kind: friction.KindFrustration, SubjectID: "s2", SubjectKind: friction.SubjectSession, + Detector: "frustration", Text: "this is still broken", Ordinal: &ord, Seq: 2, + }, + { + Kind: friction.KindInterruption, SubjectID: "s2", SubjectKind: friction.SubjectSession, + Detector: "interruption", Ordinal: &ord, Seq: 3, + }, + { + Kind: friction.KindError, SubjectID: "ci-run-7:nightly_check", SubjectKind: friction.SubjectDiagnostic, + Detector: "error", ToolName: "nightly_check", Text: "ci-run-7:nightly_check: failed", Seq: 0, + }, + }, + P0Alerts: map[string][]string{"bash": {"s1", "s2", "s3"}}, + Spend: &SpendSummary{ + Total: &total, SessionsWithStats: 3, SessionsWithCost: 2, + InputTokens: 12345, OutputTokens: 678, + RoleCosts: map[string]friction.USD{"(root)": total}, + ModelCosts: map[string]friction.USD{"claude-opus-4-8": total}, + }, + ArchiveSpend: &db.FrictionArchiveSpendSummary{ + Week: db.FrictionPeriodSpend{Total: cost, Days: 1, Agents: map[string]friction.USD{"codex": cost}, Models: map[string]friction.USD{}}, + WeekFrom: "2026-09-08", WeekTo: "2026-09-14", Timezone: "Asia/Tokyo", + }, + } +} + +func TestSnapshotRoundTrip(t *testing.T) { + in := sampleSnapshot(t) + encoded, err := EncodeSnapshot(in) + require.NoError(t, err) + out, err := DecodeSnapshot(encoded) + require.NoError(t, err) + assert.Equal(t, string(friction.RenderMarkdown(in.DigestSnapshot)), + string(friction.RenderMarkdown(out.DigestSnapshot)), + "a decoded snapshot re-renders byte-identically") + require.Len(t, out.Signals, 5) + assert.Equal(t, in.Signals[0].OccurredAt, out.Signals[0].OccurredAt) + assert.Equal(t, friction.KindInterruption, out.Signals[3].Kind) + assert.Equal(t, friction.SubjectDiagnostic, out.Signals[4].SubjectKind) + assert.Equal(t, "4.2", out.Spend.Total.String(), "scale preserved") + assert.True(t, out.Signals[1].OccurredAt.IsZero()) + + _, err = DecodeSnapshot([]byte("{")) + require.Error(t, err) +} diff --git a/internal/postgres/backend.go b/internal/postgres/backend.go index e1ddc74a7f..c4dfba96ae 100644 --- a/internal/postgres/backend.go +++ b/internal/postgres/backend.go @@ -217,6 +217,7 @@ func prepareServeStore(ctx context.Context, store *Store, schema string) error { "probing insight generation capability: %w", err, ) } + store.DetectFrictionAvailability(ctx) return nil } diff --git a/internal/postgres/friction_digest.go b/internal/postgres/friction_digest.go new file mode 100644 index 0000000000..869beb34b7 --- /dev/null +++ b/internal/postgres/friction_digest.go @@ -0,0 +1,477 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log" + "maps" + "slices" + "sort" + "strings" + "time" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +// FrictionAvailable reports whether startup proved this role can write the +// friction review tables. pg serve runs the friction-review job only then. +func (s *Store) FrictionAvailable() bool { return s.frictionAvailable.Load() } + +// frictionSessionVersionColumn selects the engine's committed row version. +// Both values change with session updates without relying on writer versions. +func (s *Store) frictionSessionVersionColumn(ctx context.Context) (string, error) { + var version string + if err := s.pg.QueryRowContext(ctx, "SELECT version()").Scan(&version); err != nil { + return "", fmt.Errorf("reading database engine: %w", err) + } + if strings.HasPrefix(version, "CockroachDB") { + return "crdb_internal_mvcc_timestamp", nil + } + return "xmin", nil +} + +// DetectFrictionAvailability probes the review tables inside a rolled-back +// transaction. A missing table or a read-only role disables the job; any +// other failure is logged and also disables it, so startup never fails on +// an optional feature (spec §20). +func (s *Store) DetectFrictionAvailability(ctx context.Context) { + for _, table := range []string{"friction_digests", "friction_digest_sessions", "friction_patterns"} { + if !pgHasTable(ctx, s.pg, table) { + s.frictionAvailable.Store(false) + return + } + } + column, err := s.frictionSessionVersionColumn(ctx) + if err == nil { + _, err = s.pg.ExecContext(ctx, "SELECT "+column+" FROM sessions LIMIT 0") + } + if err != nil { + log.Printf("friction: session version probe: %v", err) + s.frictionAvailable.Store(false) + return + } + tx, err := s.pg.BeginTx(ctx, nil) + if err != nil { + log.Printf("friction: capability probe: %v", err) + s.frictionAvailable.Store(false) + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES ('0000-00-00', 'UTC', '', NOW(), 0, '', '', '', '', '') + ON CONFLICT (date) DO NOTHING`) + if err == nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES ('friction-capability-probe', '0000-00-00', 'diagnostic') + ON CONFLICT (subject_id) DO NOTHING`) + } + if err == nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id) + VALUES ('friction-capability-probe', 'pattern', '', + '0000-00-00', '0000-00-00', 0, 0, 'friction-capability-probe') + ON CONFLICT (fingerprint) DO NOTHING`) + } + if err == nil { + for _, query := range []string{ + "UPDATE friction_digests SET revision = revision WHERE date = '0000-00-00'", + "UPDATE friction_patterns SET title = title WHERE fingerprint = 'friction-capability-probe'", + } { + if _, err = tx.ExecContext(ctx, query); err != nil { + break + } + } + } + if err != nil { + if !IsReadOnlyError(err) { + log.Printf("friction: capability probe: %v", err) + } + s.frictionAvailable.Store(false) + return + } + s.frictionAvailable.Store(true) +} + +func (s *Store) FrictionPendingDates(ctx context.Context, first, last string, loc *time.Location) ([]string, error) { + from, _, err := db.FrictionDayBounds(first, loc) + if err != nil { + return nil, err + } + _, to, err := db.FrictionDayBounds(last, loc) + if err != nil { + return nil, err + } + rows, err := s.pg.QueryContext(ctx, ` + SELECT COALESCE(s.ended_at, s.started_at) + FROM sessions s + LEFT JOIN friction_digest_sessions ds ON ds.subject_id = s.id + WHERE s.deleted_at IS NULL AND ds.subject_id IS NULL + AND s.friction_rules_version <> '' + AND s.friction_rules_version NOT LIKE '%:skipped' + AND COALESCE(s.ended_at, s.started_at) >= $1 + AND COALESCE(s.ended_at, s.started_at) < $2`, from.UTC(), to.UTC()) + if err != nil { + return nil, fmt.Errorf("listing pending friction dates: %w", err) + } + defer rows.Close() + dates := map[string]bool{} + for rows.Next() { + var ts time.Time + if err := rows.Scan(&ts); err != nil { + return nil, err + } + dates[ts.In(loc).Format("2006-01-02")] = true + } + return slices.Sorted(maps.Keys(dates)), rows.Err() +} + +const frictionSubjectsSQL = ` + WITH candidates AS ( + SELECT s.id, '' AS digest_date + FROM sessions s + WHERE s.deleted_at IS NULL + AND COALESCE(s.ended_at, s.started_at) >= $1 + AND COALESCE(s.ended_at, s.started_at) < $2 + AND NOT EXISTS (SELECT 1 FROM friction_digest_sessions ds WHERE ds.subject_id = s.id) + UNION ALL + SELECT subject_id, date FROM friction_digest_sessions WHERE $3 AND date = $4 + ) + SELECT s.id, s.machine, COALESCE(s.file_path, ''), s.agent, + (s.parent_session_id IS NOT NULL AND s.relationship_type = 'subagent'), + COALESCE(s.ended_at, s.started_at), s.friction_rules_version, + c.digest_date, s.%s::text + FROM candidates c JOIN sessions s ON s.id = c.id + WHERE s.deleted_at IS NULL AND s.friction_rules_version NOT LIKE '%%:skipped'` + +func (s *Store) FrictionSubjectsForDate( + ctx context.Context, date string, loc *time.Location, includeDigested bool, +) ([]db.FrictionSubject, error) { + from, to, err := db.FrictionDayBounds(date, loc) + if err != nil { + return nil, err + } + labels, err := s.GetMachineLabels(ctx) + if err != nil { + return nil, err + } + column, err := s.frictionSessionVersionColumn(ctx) + if err != nil { + return nil, err + } + rows, err := s.pg.QueryContext(ctx, fmt.Sprintf(frictionSubjectsSQL, column), + from.UTC(), to.UTC(), includeDigested, date) + if err != nil { + return nil, fmt.Errorf("listing friction subjects: %w", err) + } + defer rows.Close() + out := []db.FrictionSubject{} + for rows.Next() { + var ( + sub db.FrictionSubject + machine string + last sql.NullTime + digestDate string + ) + if err := rows.Scan(&sub.SubjectID, &machine, &sub.FilePath, &sub.Agent, + &sub.IsSubAgent, &last, &sub.RulesVersion, &digestDate, &sub.SessionVersion); err != nil { + return nil, fmt.Errorf("scanning friction subject: %w", err) + } + if last.Valid { + sub.LastActivity = last.Time.UTC() + } + sub.SubjectKind = friction.SubjectSession + sub.AlreadyDigested = digestDate != "" + sub.Machine = machine + if label := labels[machine]; label != "" { + sub.Machine = label + } + out = append(out, sub) + } + return out, rows.Err() +} + +func (s *Store) FrictionFindingsForSubjects( + ctx context.Context, subjectIDs []string, +) ([]db.FrictionFinding, error) { + out := []db.FrictionFinding{} + for start := 0; start < len(subjectIDs); start += 500 { + chunk := subjectIDs[start:min(start+500, len(subjectIDs))] + findings, err := s.frictionFindingsChunk(ctx, chunk) + if err != nil { + return nil, err + } + out = append(out, findings...) + } + // PG has no rowid tiebreak; seq is unique within a session's findings. + // Sort in Go with byte order so collation never differs from SQLite. + sort.SliceStable(out, func(i, j int) bool { + if out[i].SessionID != out[j].SessionID { + return out[i].SessionID < out[j].SessionID + } + return out[i].Seq < out[j].Seq + }) + return out, nil +} + +func (s *Store) frictionFindingsChunk( + ctx context.Context, subjectIDs []string, +) ([]db.FrictionFinding, error) { + pb := ¶mBuilder{} + in := pgInPlaceholders(subjectIDs, pb) + rows, err := s.pg.QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id IN `+in, pb.args...) + if err != nil { + return nil, fmt.Errorf("loading friction findings: %w", err) + } + defer rows.Close() + out := []db.FrictionFinding{} + for rows.Next() { + var ( + f db.FrictionFinding + ord, call sql.NullInt64 + occurred sql.NullTime + ) + if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, + &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, + &occurred, &f.Seq, &f.RulesVersion); err != nil { + return nil, fmt.Errorf("scanning friction finding: %w", err) + } + if ord.Valid { + f.MessageOrdinal = new(int(ord.Int64)) + } + if call.Valid { + f.CallIndex = new(int(call.Int64)) + } + if occurred.Valid { + f.OccurredAt = new(occurred.Time.UTC()) + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *Store) SaveFrictionDigest( + ctx context.Context, d db.FrictionDigest, + subjects []db.FrictionDigestSubject, patterns []db.FrictionPatternUpdate, +) error { + column, err := s.frictionSessionVersionColumn(ctx) + if err != nil { + return err + } + tx, err := s.pg.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("beginning friction digest tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + var res sql.Result + // Push updates each session in the same transaction as its findings and + // usage. All build inputs have already been read, so any push that could + // have mixed their versions is visible now. A later push cannot change + // these captured inputs and does not invalidate this snapshot. + for _, sub := range subjects { + if sub.SessionVersion == "" { + continue + } + var version string + err := tx.QueryRowContext(ctx, `SELECT `+column+`::text FROM sessions WHERE id = $1`, sub.SubjectID).Scan(&version) + if errors.Is(err, sql.ErrNoRows) { + return db.ErrFrictionSubjectsChanged + } + if err != nil { + return fmt.Errorf("checking friction session version: %w", err) + } + if version != sub.SessionVersion { + return db.ErrFrictionSubjectsChanged + } + } + if d.Revision <= 1 { + res, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + revision, sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES ($1, $2, $3, $4, 1, $5, $6, $7, $8, $9, $10) + ON CONFLICT (date) DO NOTHING`, + d.Date, d.Timezone, d.RulesVersion, d.BuiltAt.UTC(), d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID) + } else { + res, err = tx.ExecContext(ctx, ` + UPDATE friction_digests SET timezone = $1, rules_version = $2, + built_at = $3, revision = $4, sessions_scanned = $5, + snapshot_json = $6, summary_json = $7, markdown = $8, + markdown_sha256 = $9, run_id = $10 + WHERE date = $11 AND revision = $12`, + d.Timezone, d.RulesVersion, d.BuiltAt.UTC(), d.Revision, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID, d.Date, d.Revision-1) + } + if err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } else if n == 0 { + return db.ErrFrictionDigestConflict + } + for _, sub := range subjects { + res, err := tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES ($1, $2, $3) ON CONFLICT (subject_id) DO NOTHING`, + sub.SubjectID, sub.Date, sub.SubjectKind) + if err != nil { + return fmt.Errorf("recording friction subject %s: %w", sub.SubjectID, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("recording friction subject %s: %w", sub.SubjectID, err) + } else if n == 0 { + var date string + if err := tx.QueryRowContext(ctx, `SELECT date FROM friction_digest_sessions WHERE subject_id = $1`, sub.SubjectID).Scan(&date); err != nil { + return fmt.Errorf("checking friction membership: %w", err) + } + if date != d.Date { + return db.ErrFrictionMembershipConflict + } + } + } + for _, p := range patterns { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id, last_ordinal) + VALUES ($1, $2, $3, $4, $4, $5, 1, $6, $7) + ON CONFLICT (fingerprint) DO UPDATE SET + kind = EXCLUDED.kind, + title = EXCLUDED.title, + occurrence_count = friction_patterns.occurrence_count + EXCLUDED.occurrence_count, + session_count = friction_patterns.session_count + 1, + first_seen_date = LEAST(friction_patterns.first_seen_date, EXCLUDED.first_seen_date), + last_subject_id = CASE WHEN EXCLUDED.last_seen_date >= friction_patterns.last_seen_date + THEN EXCLUDED.last_subject_id ELSE friction_patterns.last_subject_id END, + last_ordinal = CASE WHEN EXCLUDED.last_seen_date >= friction_patterns.last_seen_date + THEN EXCLUDED.last_ordinal ELSE friction_patterns.last_ordinal END, + last_seen_date = GREATEST(friction_patterns.last_seen_date, EXCLUDED.last_seen_date)`, + p.Fingerprint, p.Kind, p.Title, p.Date, p.Occurrences, p.SubjectID, p.Ordinal); err != nil { + return fmt.Errorf("updating friction pattern %s: %w", p.Fingerprint, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing friction digest %s: %w", d.Date, err) + } + return nil +} + +func (s *Store) GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) { + var ( + d db.FrictionDigest + snapshot, summary, md string + ) + err := s.pg.QueryRowContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id + FROM friction_digests WHERE date = $1`, date, + ).Scan(&d.Date, &d.Timezone, &d.RulesVersion, &d.BuiltAt, &d.Revision, + &d.SessionsScanned, &snapshot, &summary, &md, &d.MarkdownSHA256, &d.RunID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading friction digest %s: %w", date, err) + } + d.BuiltAt = d.BuiltAt.UTC() + d.SnapshotJSON, d.SummaryJSON, d.Markdown = []byte(snapshot), []byte(summary), []byte(md) + return &d, nil +} + +func (s *Store) LatestFrictionDigestDate(ctx context.Context) (string, error) { + var date string + if err := s.pg.QueryRowContext(ctx, + `SELECT COALESCE(MAX(date), '') FROM friction_digests`, + ).Scan(&date); err != nil { + return "", fmt.Errorf("reading latest friction digest date: %w", err) + } + return date, nil +} + +func (s *Store) EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) { + var earliest sql.NullTime + if err := s.pg.QueryRowContext(ctx, ` + SELECT MIN(COALESCE(ended_at, started_at)) FROM sessions + WHERE deleted_at IS NULL`, + ).Scan(&earliest); err != nil { + return "", fmt.Errorf("reading earliest session date: %w", err) + } + if !earliest.Valid { + return "", nil + } + return earliest.Time.In(loc).Format("2006-01-02"), nil +} + +func (s *Store) UpdateFrictionDigestRender( + ctx context.Context, date string, markdown, summaryJSON []byte, revision int, +) error { + sum := sha256.Sum256(markdown) + res, err := s.pg.ExecContext(ctx, ` + UPDATE friction_digests + SET markdown = $1, summary_json = $2, markdown_sha256 = $3, revision = $4 + WHERE date = $5 AND revision = $6`, + string(markdown), string(summaryJSON), hex.EncodeToString(sum[:]), + revision, date, revision-1) + if err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } else if n == 0 { + return db.ErrFrictionDigestConflict + } + return nil +} + +func (s *Store) FrictionUsageForSessions( + ctx context.Context, sessionIDs []string, +) (map[string]db.FrictionSessionUsage, error) { + return db.FrictionUsageForSessionsFrom(ctx, s, sessionIDs) +} + +func (s *Store) FrictionArchiveSpend( + ctx context.Context, from, to string, loc *time.Location, +) (*db.FrictionArchiveSpendSummary, error) { + return db.FrictionArchiveSpendFrom(ctx, s, from, to, loc) +} + +// FrictionDigestDates lists stored dates without loading their snapshots. +func (s *Store) FrictionDigestDates(ctx context.Context, first, last string) ([]string, error) { + rows, err := s.pg.QueryContext(ctx, `SELECT date FROM friction_digests WHERE date >= $1 AND date <= $2 ORDER BY date`, first, last) + if err != nil { + return nil, fmt.Errorf("listing friction digest dates: %w", err) + } + defer rows.Close() + var dates []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + return nil, err + } + dates = append(dates, date) + } + return dates, rows.Err() +} diff --git a/internal/postgres/friction_digest_pgtest_test.go b/internal/postgres/friction_digest_pgtest_test.go new file mode 100644 index 0000000000..38da3c3616 --- /dev/null +++ b/internal/postgres/friction_digest_pgtest_test.go @@ -0,0 +1,284 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "encoding/json/v2" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +// This contract also runs against CockroachDB through TEST_PG_URL. It needs +// only the existing replica session tables and the new digest tables. +func TestFrictionSessionVersionPreventsStaleSave(t *testing.T) { + const schema = "agentsview_friction_version_test" + pgURL := testPGURL(t) + cleanNamedPGSchema(t, pgURL, schema) + t.Cleanup(func() { cleanNamedPGSchema(t, pgURL, schema) }) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + pg, err := Open(pgURL, schema, true) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, pg.Close()) }) + _, err = pg.ExecContext(ctx, "CREATE SCHEMA "+schema) + require.NoError(t, err) + // The first two core statements create sync_metadata and sessions. Commit + // each DDL statement independently, as CockroachDB schema changes require. + statements := append(strings.Split(coreDDL, ";\n")[:2], strings.Split(frictionReviewDDL, ";\n")...) + for _, statement := range statements { + if strings.TrimSpace(statement) != "" { + _, err = pg.ExecContext(ctx, statement) + require.NoError(t, err) + } + } + store := &Store{pg: pg} + store.DetectFrictionAvailability(ctx) + require.True(t, store.FrictionAvailable()) + _, err = pg.ExecContext(ctx, `INSERT INTO sessions (id, machine, project, agent, ended_at, friction_rules_version) + VALUES ('session', 'machine', 'project', 'claude', '2026-09-15T12:00:00Z', 'friction-v1')`) + require.NoError(t, err) + subjects, err := store.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false) + require.NoError(t, err) + require.Len(t, subjects, 1) + _, err = pg.ExecContext(ctx, `UPDATE sessions SET ended_at = '2026-09-16T12:00:00Z' WHERE id = 'session'`) + require.NoError(t, err) + digest := db.FrictionDigest{Date: "2026-09-15", Timezone: "UTC", BuiltAt: time.Now(), Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}"), Markdown: []byte("digest"), SessionsScanned: 1} + members := []db.FrictionDigestSubject{{SubjectID: "session", Date: digest.Date, + SubjectKind: friction.SubjectSession, SessionVersion: subjects[0].SessionVersion}} + err = store.SaveFrictionDigest(ctx, digest, members, nil) + require.ErrorIs(t, err, db.ErrFrictionSubjectsChanged) + stored, err := store.GetFrictionDigest(ctx, digest.Date) + require.NoError(t, err) + assert.Nil(t, stored) + subjects, err = store.FrictionSubjectsForDate(ctx, "2026-09-16", time.UTC, false) + require.NoError(t, err) + require.Len(t, subjects, 1) + digest.Date = "2026-09-16" + members[0].Date, members[0].SessionVersion = digest.Date, subjects[0].SessionVersion + require.NoError(t, store.SaveFrictionDigest(ctx, digest, members, nil)) + stored, err = store.GetFrictionDigest(ctx, digest.Date) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, 1, stored.SessionsScanned) +} + +type frictionReviewStore interface { + FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) + FrictionFindingsForSubjects(ctx context.Context, ids []string) ([]db.FrictionFinding, error) + SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, s []db.FrictionDigestSubject, p []db.FrictionPatternUpdate) error + GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) + LatestFrictionDigestDate(ctx context.Context) (string, error) + FrictionDigestDates(ctx context.Context, first, last string) ([]string, error) + EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) + UpdateFrictionDigestRender(ctx context.Context, date string, md, summary []byte, revision int) error +} + +func openFrictionParityStores(t *testing.T) (*db.DB, *Store) { + t.Helper() + pgURL := testPGURL(t) + const schema = "agentsview_friction_digest_test" + cleanNamedPGSchema(t, pgURL, schema) + t.Cleanup(func() { cleanNamedPGSchema(t, pgURL, schema) }) + ctx := t.Context() + pg, err := Open(pgURL, schema, true) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Close() }) + require.NoError(t, EnsureSchema(ctx, pg, schema)) + + local, err := db.Open(ctx, filepath.Join(t.TempDir(), "local.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = local.Close() }) + seed := func(id, ended string, parent *string) { + s := db.Session{ + ID: id, Project: "p", Machine: "machine", Agent: "claude", + EndedAt: &ended, MessageCount: 1, UserMessageCount: 1, ParentSessionID: parent, + } + if parent != nil { + s.RelationshipType = "subagent" + } + require.NoError(t, local.UpsertSession(ctx, s)) + require.NoError(t, local.InsertMessages(ctx, []db.Message{{ + SessionID: id, Ordinal: 0, + Role: "user", Content: "hi", ContentLength: 2, Timestamp: ended, + }})) + occurred := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + require.NoError(t, local.UpdateSessionSignals(ctx, id, db.SessionSignalUpdate{Friction: &db.SessionFrictionUpdate{Findings: []db.FrictionFinding{{ + SessionID: id, Kind: "deferral", Detector: "deferral", MessageOrdinal: new(0), + Label: "next session", Title: "[friction/deferral] " + id + ": next session", + Fingerprint: "fl1:" + id, OccurredAt: &occurred, Seq: 0, RulesVersion: "friction-v1", + }}, RulesVersion: "friction-v1", Hash: "h-" + id}})) + } + seed("a", "2026-09-15T01:00:00Z", nil) + root := "a" + seed("b", "2026-09-15T23:30:00Z", &root) + seed("c", "2026-09-16T00:30:00Z", nil) + seed("excluded", "2026-09-15T02:00:00Z", nil) + require.NoError(t, local.UpdateSessionSignals(ctx, "excluded", db.SessionSignalUpdate{Friction: &db.SessionFrictionUpdate{Findings: nil, RulesVersion: friction.SkippedRulesVersion, Hash: "h-excluded"}})) + + syncer := &Sync{pg: pg, local: local, machine: "machine", schema: schema, schemaDone: true} + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + + store, err := NewStore(pgURL, schema, true) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + return local, store +} + +func TestFrictionReviewStoreParity(t *testing.T) { + local, pgStore := openFrictionParityStores(t) + for name, s := range map[string]frictionReviewStore{"sqlite": local, "postgres": pgStore} { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + subjects, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false) + require.NoError(t, err) + ids := []string{} + for _, sub := range subjects { + ids = append(ids, sub.SubjectID) + if sub.SubjectID == "b" { + assert.True(t, sub.IsSubAgent) + } + assert.Equal(t, "friction-v1", sub.RulesVersion) + } + assert.ElementsMatch(t, []string{"a", "b"}, ids) + + findings, err := s.FrictionFindingsForSubjects(ctx, []string{"b", "a"}) + require.NoError(t, err) + require.Len(t, findings, 2) + assert.Equal(t, "a", findings[0].SessionID) + assert.Equal(t, "next session", findings[0].Label) + + earliest, err := s.EarliestSessionDate(ctx, time.UTC) + require.NoError(t, err) + assert.Equal(t, "2026-09-15", earliest) + + d := db.FrictionDigest{ + Date: "2026-09-15", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2026, 9, 22, 1, 0, 0, 0, time.UTC), Revision: 1, SessionsScanned: 2, + SnapshotJSON: []byte(`{"date":"2026-09-15"}`), SummaryJSON: []byte("{}\n"), + Markdown: []byte("md\n"), MarkdownSHA256: "abc", RunID: "run-1", + } + pats := []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:a", Kind: "deferral", Title: "t", Date: "2026-09-15", SubjectID: "a", Ordinal: new(0), Occurrences: 1}, + } + require.NoError(t, s.SaveFrictionDigest(ctx, d, + []db.FrictionDigestSubject{{SubjectID: "a", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, pats)) + require.ErrorIs(t, s.SaveFrictionDigest(ctx, d, nil, pats), db.ErrFrictionDigestConflict) + got, err := s.GetFrictionDigest(ctx, "2026-09-15") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, d, *got) + + otherDate := d + otherDate.Date = "2026-09-16" + require.ErrorIs(t, s.SaveFrictionDigest(ctx, otherDate, + []db.FrictionDigestSubject{ + {SubjectID: "b", Date: "2026-09-16", SubjectKind: friction.SubjectSession}, + {SubjectID: "a", Date: "2026-09-16", SubjectKind: friction.SubjectSession}, + }, pats), db.ErrFrictionMembershipConflict) + loser, err := s.GetFrictionDigest(ctx, "2026-09-16") + require.NoError(t, err) + assert.Nil(t, loser, "the cross-date conflict rolls back the digest and all memberships") + + after, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false) + require.NoError(t, err) + require.Len(t, after, 1) + assert.Equal(t, "b", after[0].SubjectID) + all, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, true) + require.NoError(t, err) + require.Len(t, all, 2) + membership := map[string]bool{} + for _, subject := range all { + membership[subject.SubjectID] = subject.AlreadyDigested + } + assert.Equal(t, map[string]bool{"a": true, "b": false}, membership) + + latest, err := s.LatestFrictionDigestDate(ctx) + require.NoError(t, err) + assert.Equal(t, "2026-09-15", latest) + dates, err := s.FrictionDigestDates(ctx, "2026-09-15", "2026-09-21") + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-15"}, dates) + dates, err = s.FrictionDigestDates(ctx, "2026-09-16", "2026-09-21") + require.NoError(t, err) + assert.Empty(t, dates) + require.NoError(t, s.UpdateFrictionDigestRender(ctx, "2026-09-15", []byte("md2\n"), []byte("{}\n"), 2)) + require.ErrorIs(t, s.UpdateFrictionDigestRender(ctx, "2026-09-15", []byte("x"), []byte("{}"), 2), db.ErrFrictionDigestConflict) + missing, err := s.GetFrictionDigest(ctx, "2020-01-01") + require.NoError(t, err) + assert.Nil(t, missing) + }) + } +} + +func TestFrictionSubjectsSeekActivityAndMembership(t *testing.T) { + _, store := openFrictionParityStores(t) + _, err := store.pg.ExecContext(t.Context(), `INSERT INTO friction_digest_sessions(subject_id, date, subject_kind) + VALUES ('c', '2026-09-15', 'session')`) + require.NoError(t, err) + for _, history := range []int{1, 1024} { + _, err = store.pg.ExecContext(t.Context(), `INSERT INTO sessions(id, project, machine, agent, ended_at, friction_rules_version) + SELECT 'old-'||n, 'project', 'machine', 'claude', '2000-01-01T12:00:00Z', 'friction-v1' + FROM generate_series(1,$1::int)n ON CONFLICT DO NOTHING`, history) + require.NoError(t, err) + _, err = store.pg.ExecContext(t.Context(), `ANALYZE sessions; ANALYZE friction_digest_sessions`) + require.NoError(t, err) + for _, rebuild := range []bool{false, true} { + subjects, err := store.FrictionSubjectsForDate(t.Context(), "2026-09-15", time.UTC, rebuild) + require.NoError(t, err) + var ids []string + for _, sub := range subjects { + ids = append(ids, sub.SubjectID) + } + want := []string{"a", "b"} + if rebuild { + want = append(want, "c") + } + assert.ElementsMatch(t, want, ids) + for _, mode := range []string{"force_custom_plan", "force_generic_plan"} { + t.Run(fmt.Sprintf("%d/%t/%s", history, rebuild, mode), func(t *testing.T) { + tx, err := store.pg.BeginTx(t.Context(), nil) + require.NoError(t, err) + defer tx.Rollback() + _, err = tx.ExecContext(t.Context(), `SET LOCAL plan_cache_mode=`+mode) + require.NoError(t, err) + _, err = tx.ExecContext(t.Context(), `PREPARE friction_subjects(timestamptz,timestamptz,bool,text) AS `+fmt.Sprintf(frictionSubjectsSQL, "xmin")) + require.NoError(t, err) + var encoded []byte + err = tx.QueryRowContext(t.Context(), fmt.Sprintf(`EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) EXECUTE friction_subjects('2026-09-15T00:00:00Z','2026-09-16T00:00:00Z',%t,'2026-09-15')`, rebuild)).Scan(&encoded) + require.NoError(t, err) + _, err = tx.ExecContext(t.Context(), `DEALLOCATE friction_subjects`) + require.NoError(t, err) + var plans []struct{ Plan map[string]any } + require.NoError(t, json.Unmarshal(encoded, &plans)) + require.Len(t, plans, 1) + var visit func(map[string]any) + visit = func(node map[string]any) { + if node["Relation Name"] == "sessions" { + rows, _ := node["Actual Rows"].(float64) + removed, _ := node["Rows Removed by Filter"].(float64) + assert.LessOrEqual(t, rows+removed, float64(8), "unchanged history was scanned: %v", node) + } + if children, ok := node["Plans"].([]any); ok { + for _, child := range children { + visit(child.(map[string]any)) + } + } + } + visit(plans[0].Plan) + }) + } + } + } +} diff --git a/internal/postgres/friction_review_pgtest_test.go b/internal/postgres/friction_review_pgtest_test.go new file mode 100644 index 0000000000..368dd4034b --- /dev/null +++ b/internal/postgres/friction_review_pgtest_test.go @@ -0,0 +1,260 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "database/sql" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" +) + +type pausedFrictionSelection struct { + review.Store + selected, resume chan struct{} + once sync.Once +} + +func (s *pausedFrictionSelection) FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) { + subjects, err := s.Store.FrictionSubjectsForDate(ctx, date, loc, includeDigested) + if err != nil { + return nil, err + } + s.once.Do(func() { + close(s.selected) + select { + case <-s.resume: + case <-ctx.Done(): + } + }) + return subjects, ctx.Err() +} + +func TestFrictionConcurrentBuildsAfterActivityMoves(t *testing.T) { + for _, tc := range []struct { + name, backend string + competingBuild bool + }{ + {"sqlite builders", "sqlite", true}, + {"postgres builders", "postgres", true}, + {"postgres push without another builder", "postgres", false}, + } { + t.Run(tc.name, func(t *testing.T) { + local, hub := openFrictionParityStores(t) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + var firstStore, secondStore review.Store = local, local + var counts *sql.DB + if tc.backend == "postgres" { + other, err := NewStore(testPGURL(t), "agentsview_friction_digest_test", true) + require.NoError(t, err) + t.Cleanup(func() { _ = other.Close() }) + firstStore, secondStore, counts = hub, other, hub.DB() + } else { + var err error + counts, err = sql.Open("sqlite3", local.Path()) + require.NoError(t, err) + t.Cleanup(func() { _ = counts.Close() }) + } + paused := &pausedFrictionSelection{Store: firstStore, selected: make(chan struct{}), resume: make(chan struct{})} + var resumeOnce sync.Once + resume := func() { resumeOnce.Do(func() { close(paused.resume) }) } + defer resume() + now := func() time.Time { return time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) } + first := &review.Runner{Store: paused, Now: now} + second := &review.Runner{Store: secondStore, Now: now} + done := make(chan struct{}) + var firstReport review.Report + var buildErr error + go func() { + defer close(done) + firstReport, buildErr = first.BuildDate(ctx, "2026-09-15", review.BuildOptions{}) + }() + defer func() { + cancel() + resume() + <-done + }() + select { + case <-paused.selected: + case <-ctx.Done(): + require.NoError(t, ctx.Err()) + } + + // The first builder has selected a and b. A sync moves a to the + // next day and replaces its findings before the build reads them. + session, err := local.GetSession(ctx, "a") + require.NoError(t, err) + require.NotNil(t, session) + session.EndedAt = new("2026-09-16T01:00:00Z") + require.NoError(t, local.UpsertSession(ctx, *session)) + require.NoError(t, local.UpdateSessionSignals(ctx, "a", db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{RulesVersion: friction.RulesVersion, Hash: "resumed", + Findings: []db.FrictionFinding{{SessionID: "a", Kind: "correction", Detector: "correction.coding", + Text: "changed after selection", Title: "updated", Fingerprint: "updated", RulesVersion: friction.RulesVersion}}, + }, + })) + if tc.backend == "postgres" { + syncer := &Sync{pg: hub.pg, local: local, machine: "machine", schema: "agentsview_friction_digest_test", schemaDone: true} + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + } + buildSecond := func() { + secondReport, err := second.BuildDate(ctx, "2026-09-16", review.BuildOptions{}) + require.NoError(t, err) + require.True(t, secondReport.Written) + } + if tc.competingBuild { + buildSecond() + } + resume() + select { + case <-done: + require.NoError(t, buildErr) + case <-ctx.Done(): + require.NoError(t, ctx.Err()) + } + require.True(t, firstReport.Written) + storedFirst, err := firstStore.GetFrictionDigest(ctx, "2026-09-15") + require.NoError(t, err) + require.NotNil(t, storedFirst) + assert.Equal(t, 1, storedFirst.SessionsScanned) + snapshot, err := review.DecodeSnapshot(storedFirst.SnapshotJSON) + require.NoError(t, err) + require.Len(t, snapshot.Signals, 1) + assert.Equal(t, "b", snapshot.Signals[0].SubjectID) + if !tc.competingBuild { + buildSecond() + } + storedSecond, err := secondStore.GetFrictionDigest(ctx, "2026-09-16") + require.NoError(t, err) + require.NotNil(t, storedSecond) + assert.Equal(t, 2, storedSecond.SessionsScanned) + assert.Contains(t, string(storedSecond.Markdown), "changed after selection") + var sessions, occurrences int + require.NoError(t, counts.QueryRowContext(ctx, `SELECT SUM(session_count), SUM(occurrence_count) FROM friction_patterns`).Scan(&sessions, &occurrences)) + assert.Equal(t, 3, sessions, "each of the three reviewed sessions contributes once") + assert.Equal(t, 3, occurrences) + }) + } +} + +// The same archive digested on SQLite and on its PostgreSQL hub produces +// identical Markdown and summary JSON. +func TestFrictionRunnerParitySQLiteAndPG(t *testing.T) { + local, pgStore := openFrictionParityStores(t) + now := func() time.Time { return time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) } + build := func(s review.Store) ([]byte, []byte) { + r := &review.Runner{Store: s, Loc: time.UTC, Now: now, BackfillDays: 7} + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + require.Equal(t, []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-18", "2026-09-19", "2026-09-20", "2026-09-21"}, + func() []string { + out := []string{} + for _, rep := range reps { + out = append(out, rep.Date) + } + return out + }()) + d, err := s.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + require.NotNil(t, d) + return d.Markdown, d.SummaryJSON + } + sqliteMD, sqliteSummary := build(local) + pgMD, pgSummary := build(pgStore) + assert.Equal(t, string(sqliteMD), string(pgMD)) + assert.Equal(t, string(sqliteSummary), string(pgSummary)) + assert.Contains(t, string(pgMD), "`agent:claude`") +} + +func TestFrictionHubCatchesUpAfterLatePush(t *testing.T) { + local, hub := openFrictionParityStores(t) + r := &review.Runner{Store: hub, Loc: time.UTC, Now: func() time.Time { + return time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC) + }, BackfillDays: 7} + _, err := r.CatchUp(t.Context()) + require.NoError(t, err) + for _, s := range []struct{ id, version string }{ + {"late-old", "friction-v0"}, {"late-new", "friction-v2"}, {"disabled", ""}, + {"skipped-old", "friction-v0:skipped"}, {"skipped-new", "friction-v2:skipped"}, + } { + ended := "2026-09-15T12:00:00Z" + require.NoError(t, local.UpsertSession(t.Context(), db.Session{ + ID: s.id, Project: "p", Machine: "machine", Agent: "claude", EndedAt: &ended, + })) + var findings []db.FrictionFinding + if !strings.HasSuffix(s.version, ":skipped") { + findings = []db.FrictionFinding{{SessionID: s.id, Kind: "correction", Detector: "correction.coding", + Text: s.id, Title: s.id, Fingerprint: s.id, RulesVersion: s.version}} + } + require.NoError(t, local.UpdateSessionSignals(t.Context(), s.id, db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{RulesVersion: s.version, Hash: s.id, + Findings: findings, + }, + })) + } + syncer := &Sync{pg: hub.pg, local: local, machine: "machine", schema: "agentsview_friction_digest_test", schemaDone: true} + _, err = syncer.Push(t.Context(), true, nil) + require.NoError(t, err) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + require.Len(t, reps, 1) + assert.Equal(t, "2026-09-15", reps[0].Date) + got, err := hub.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 4, got.SessionsScanned) + assert.Contains(t, string(got.Markdown), "late-old") + assert.Contains(t, string(got.Markdown), "late-new") + assert.NotContains(t, string(got.Markdown), "disabled") + var count int + require.NoError(t, hub.pg.QueryRowContext(t.Context(), `SELECT session_count FROM friction_patterns WHERE last_subject_id = 'a'`).Scan(&count)) + assert.Equal(t, 1, count, "rebuilding a date must not recount previous members") + again, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Empty(t, again) +} + +func TestFrictionMidnightDSTParity(t *testing.T) { + local, hub := openFrictionParityStores(t) + loc, err := time.LoadLocation("America/Santiago") + require.NoError(t, err) + for _, s := range []struct{ id, ended string }{ + {"before", "2026-09-06T03:59:59Z"}, {"first", "2026-09-06T04:00:00Z"}, + {"late", "2026-09-07T02:59:59Z"}, {"after", "2026-09-07T03:00:00Z"}, + } { + require.NoError(t, local.UpsertSession(t.Context(), db.Session{ + ID: s.id, Project: "p", Machine: "machine", Agent: "claude", EndedAt: &s.ended, + })) + require.NoError(t, local.UpdateSessionSignals(t.Context(), s.id, db.SessionSignalUpdate{ + Friction: &db.SessionFrictionUpdate{RulesVersion: friction.RulesVersion, Hash: s.id}, + })) + } + syncer := &Sync{pg: hub.pg, local: local, machine: "machine", schema: "agentsview_friction_digest_test", schemaDone: true} + _, err = syncer.Push(t.Context(), true, nil) + require.NoError(t, err) + for name, store := range map[string]review.Store{"sqlite": local, "postgres": hub} { + t.Run(name, func(t *testing.T) { + subjects, err := store.FrictionSubjectsForDate(t.Context(), "2026-09-06", loc, false) + require.NoError(t, err) + ids := []string{} + for _, s := range subjects { + ids = append(ids, s.SubjectID) + } + assert.ElementsMatch(t, []string{"first", "late"}, ids) + dates, err := store.FrictionPendingDates(t.Context(), "2026-09-06", "2026-09-06", loc) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-06"}, dates) + }) + } +} diff --git a/internal/postgres/friction_schema.go b/internal/postgres/friction_schema.go index 70be3aad56..77025f85e6 100644 --- a/internal/postgres/friction_schema.go +++ b/internal/postgres/friction_schema.go @@ -31,6 +31,9 @@ CREATE INDEX IF NOT EXISTS idx_friction_findings_session var hostedFrictionTables = []HostedTable{ {Name: "friction_findings", Key: []string{"id"}, ForeignKeys: sessionHostedFK()}, + {Name: "friction_digests", Key: []string{"date"}}, + {Name: "friction_digest_sessions", Key: []string{"subject_id"}}, + {Name: "friction_patterns", Key: []string{"fingerprint"}}, } // installHostedFrictionUpgrade adds missing derived tables and repairs their @@ -49,6 +52,9 @@ func installHostedFrictionUpgrade(ctx context.Context, tx *sql.Tx, schema, tenan if _, err := tx.ExecContext(ctx, frictionDDL); err != nil { return err } + if _, err := tx.ExecContext(ctx, frictionReviewDDL); err != nil { + return err + } if len(missing) > 0 { if err := InstallHostedTables(ctx, tx, schema, tenant, missing); err != nil { return err diff --git a/internal/postgres/hosted_tenant.go b/internal/postgres/hosted_tenant.go index e901865adc..ccd8261936 100644 --- a/internal/postgres/hosted_tenant.go +++ b/internal/postgres/hosted_tenant.go @@ -82,7 +82,7 @@ func EnsureHostedTenant(ctx context.Context, database *sql.DB, schema, tenant st if _, err = tx.ExecContext(ctx, `CREATE SCHEMA IF NOT EXISTS `+quoted); err != nil { return err } - for _, ddl := range []string{coreDDL, frictionDDL, rawIngestDDL, rawIngestAppendOnlyDDL, postgresUsageJSONHelperDDL, rawProjectionDDL, rawLinksDDL} { + for _, ddl := range []string{coreDDL, frictionDDL, frictionReviewDDL, rawIngestDDL, rawIngestAppendOnlyDDL, postgresUsageJSONHelperDDL, rawProjectionDDL, rawLinksDDL} { if _, err = tx.ExecContext(ctx, ddl); err != nil { return fmt.Errorf("creating hosted application schema: %w", err) } diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index 70661d8668..151d911377 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -662,6 +662,52 @@ CREATE INDEX IF NOT EXISTS idx_insights_lookup CREATE INDEX IF NOT EXISTS idx_insights_cache ON insights (cache_key, created_at DESC) WHERE cache_key <> ''; + +` + +// frictionReviewDDL adds daily state without altering the shipped findings schema. +const frictionReviewDDL = ` +CREATE INDEX IF NOT EXISTS idx_sessions_friction_activity + ON sessions(COALESCE(ended_at, started_at)) + WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS friction_digests ( + date TEXT PRIMARY KEY, + timezone TEXT NOT NULL, + rules_version TEXT NOT NULL, + built_at TIMESTAMPTZ NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + sessions_scanned INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + summary_json TEXT NOT NULL, + markdown TEXT NOT NULL, + markdown_sha256 TEXT NOT NULL, + run_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS friction_digest_sessions ( + subject_id TEXT PRIMARY KEY, + date TEXT NOT NULL, + subject_kind TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_friction_digest_sessions_date + ON friction_digest_sessions (date); + +CREATE TABLE IF NOT EXISTS friction_patterns ( + fingerprint TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + first_seen_date TEXT NOT NULL, + last_seen_date TEXT NOT NULL, + occurrence_count INTEGER NOT NULL, + session_count INTEGER NOT NULL, + last_subject_id TEXT NOT NULL, + last_ordinal INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_friction_patterns_last_seen + ON friction_patterns (last_seen_date); ` func migrateMoneyColumnsPG( @@ -998,6 +1044,9 @@ func EnsureSchema( if _, err := db.ExecContext(ctx, frictionDDL); err != nil { return fmt.Errorf("creating pg friction tables: %w", err) } + if _, err := db.ExecContext(ctx, frictionReviewDDL); err != nil { + return fmt.Errorf("creating pg friction review tables: %w", err) + } if err := ensureRawIngestSchemaPG(ctx, db); err != nil { return err } @@ -2462,6 +2511,9 @@ func pushSchemaCurrent(ctx context.Context, db *sql.DB) bool { !pgHasTable(ctx, db, "source_session_project_identity_snapshot_scopes") || !pgHasTable(ctx, db, "source_worktree_project_mappings") || !pgHasTable(ctx, db, "source_worktree_project_mapping_scopes") || + !pgHasTable(ctx, db, "friction_digests") || + !pgHasTable(ctx, db, "friction_digest_sessions") || + !pgHasTable(ctx, db, "friction_patterns") || !pgHasTable(ctx, db, "cursor_usage_events") || !pgHasTable(ctx, db, "friction_findings") { return false diff --git a/internal/postgres/schema_test.go b/internal/postgres/schema_test.go index 697e864b95..de0744844b 100644 --- a/internal/postgres/schema_test.go +++ b/internal/postgres/schema_test.go @@ -598,6 +598,9 @@ func TestSyncEnsureSchemaSkipsLegacyDDLWhenSchemaCompatible(t *testing.T) { "source_worktree_project_mapping_scopes": true, "cursor_usage_events": true, "friction_findings": true, + "friction_digests": true, + "friction_digest_sessions": true, + "friction_patterns": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, @@ -639,6 +642,9 @@ func TestEnsureSchemaScrubsProjectIdentityGitRemoteCredentials(t *testing.T) { "source_worktree_project_mapping_scopes": true, "cursor_usage_events": true, "friction_findings": true, + "friction_digests": true, + "friction_digest_sessions": true, + "friction_patterns": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 5b256155f6..e314f19f81 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -11,6 +11,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "go.kenn.io/agentsview/internal/config" @@ -29,6 +30,7 @@ type Store struct { insightCapabilityMu sync.RWMutex insightGenerationAvailable bool + frictionAvailable atomic.Bool pricingMu sync.Mutex pricingLoadMu sync.Mutex diff --git a/internal/server/friction_capability_test.go b/internal/server/friction_capability_test.go new file mode 100644 index 0000000000..8707f2ff19 --- /dev/null +++ b/internal/server/friction_capability_test.go @@ -0,0 +1,34 @@ +package server_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/server" +) + +func TestVersionAdvertisesFriction(t *testing.T) { + tests := []struct { + name string + enable bool + }{ + {name: "disabled", enable: false}, + {name: "enabled", enable: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opts []server.Option + te := setupWithServerOpts(t, nil) + if tt.enable { + runner := &review.Runner{Store: te.db, Loc: time.UTC, Now: time.Now, BackfillDays: 7} + opts = append(opts, server.WithFriction(runner)) + te = setupWithServerOpts(t, opts) + } + version := decode[map[string]any](t, te.get(t, "/api/v1/version")) + assert.Equal(t, tt.enable, version["friction_available"]) + }) + } +} diff --git a/internal/server/huma_routes.go b/internal/server/huma_routes.go index 6274b5b66c..0b0f3b00a0 100644 --- a/internal/server/huma_routes.go +++ b/internal/server/huma_routes.go @@ -538,12 +538,12 @@ func (s *Server) rejectWriterClosedWrite() error { return nil } -// serializeArchiveWrite runs work under the daemon engine's exclusive lock — +// SerializeArchiveWrite runs work under the daemon engine's exclusive lock — // the same mutex a worker pass holds while the writer is closed — so a write // that passed the pre-stream writer gate cannot race a maintenance pass // closing the writer mid-operation. Local servers without a daemon engine // share the on-demand sync engine's lock. -func (s *Server) serializeArchiveWrite(ctx context.Context, work func() error) error { +func (s *Server) SerializeArchiveWrite(ctx context.Context, work func() error) error { if s.engine != nil { return s.engine.RunExclusive(work) } @@ -556,7 +556,7 @@ func (s *Server) serializeArchiveWrite(ctx context.Context, work func() error) e // tryArchiveWrite runs user-triggered archive maintenance under the daemon // engine's exclusive lock without waiting for it, which is the barrier // newForegroundCompactRunner puts compaction behind. Background work keeps -// serializeArchiveWrite so scheduled obligations are not lost. Local servers +// SerializeArchiveWrite so scheduled obligations are not lost. Local servers // without a daemon engine share the on-demand sync engine's lock. func (s *Server) tryArchiveWrite(ctx context.Context, work func() error) error { if s.engine != nil { diff --git a/internal/server/huma_routes_import.go b/internal/server/huma_routes_import.go index b3b1b8fcf3..1c37a4bedb 100644 --- a/internal/server/huma_routes_import.go +++ b/internal/server/huma_routes_import.go @@ -176,7 +176,7 @@ func (s *Server) humaSyncClaudeAI(ctx context.Context, in *claudeAISyncInput, re } stats, err := importer.SyncClaudeAI(ctx, store, fetch, &importer.ImportCallbacks{ SerializeWrite: func(write func() error) error { - return s.serializeArchiveWrite(ctx, write) + return s.SerializeArchiveWrite(ctx, write) }, OnProgress: func(stats importer.ImportStats) { if !stream.SendJSON("progress", stats) { @@ -290,7 +290,7 @@ func (s *Server) importClaudeAIFromFileWithCallbacks( } defer cleanup() var stats importer.ImportStats - err = s.serializeArchiveWrite(ctx, func() error { + err = s.SerializeArchiveWrite(ctx, func() error { var importErr error stats, importErr = importer.ImportClaudeAIWithOptions(ctx, s.db, reader, cb, opts) return importErr @@ -433,7 +433,7 @@ func (s *Server) importChatGPTFromFile( } defer cleanup() var stats importer.ImportStats - err = s.serializeArchiveWrite(ctx, func() error { + err = s.SerializeArchiveWrite(ctx, func() error { var importErr error stats, importErr = importer.ImportChatGPTWithOptions(ctx, s.db, dir, filepath.Join(s.cfg.DataDir, "assets"), cb, opts) diff --git a/internal/server/huma_routes_insights.go b/internal/server/huma_routes_insights.go index 72e0c8aa43..19176743dd 100644 --- a/internal/server/huma_routes_insights.go +++ b/internal/server/huma_routes_insights.go @@ -486,7 +486,7 @@ func (s *Server) humaGenerateInsight( promptPtr = &req.Prompt } var id int64 - err = s.serializeArchiveWrite(genCtx, func() error { + err = s.SerializeArchiveWrite(genCtx, func() error { var insertErr error id, insertErr = s.db.InsertInsight(genCtx, db.Insight{ Type: req.Type, diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index b5fe6c7485..09877e5507 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -207,6 +207,7 @@ func (s *Server) humaGetVersion( version.InsightGenerationAvailable = false version.SessionStatsAvailable = false } + version.FrictionAvailable = s.frictionRunner != nil return &jsonOutput[VersionInfo]{Body: version}, nil } diff --git a/internal/server/huma_routes_sync_internal_test.go b/internal/server/huma_routes_sync_internal_test.go index 5ba650199e..392c8b4859 100644 --- a/internal/server/huma_routes_sync_internal_test.go +++ b/internal/server/huma_routes_sync_internal_test.go @@ -1471,7 +1471,7 @@ func TestArchiveMaintenanceNoSyncSharesBarrier(t *testing.T) { run func(*Server, context.Context, func() error) error }{ {"foreground", (*Server).tryArchiveWrite}, - {"background", (*Server).serializeArchiveWrite}, + {"background", (*Server).SerializeArchiveWrite}, } { t.Run(tc.name, func(t *testing.T) { f := newSyncRouteFixture(t) diff --git a/internal/server/insights.go b/internal/server/insights.go index 9f144228f4..033112f228 100644 --- a/internal/server/insights.go +++ b/internal/server/insights.go @@ -349,7 +349,7 @@ func (s *Server) generateCannedInsight( } var id int64 - err = s.serializeArchiveWrite(ctx, func() error { + err = s.SerializeArchiveWrite(ctx, func() error { var insertErr error id, insertErr = s.db.InsertInsight(ctx, db.Insight{ Type: insight.CannedType, diff --git a/internal/server/server.go b/internal/server/server.go index 22c3021d42..5ddf21c482 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -27,6 +27,7 @@ import ( "go.kenn.io/agentsview/internal/codexcloud" "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction/review" "go.kenn.io/agentsview/internal/insight" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/pricingrefresh" @@ -49,6 +50,7 @@ type VersionInfo struct { ArchiveOnly bool `json:"archive_only,omitempty"` InsightGenerationAvailable bool `json:"insight_generation_available"` SessionStatsAvailable bool `json:"session_stats_available"` + FrictionAvailable bool `json:"friction_available"` APIVersion int `json:"api_version"` DataVersion int `json:"data_version"` } @@ -209,6 +211,7 @@ type Server struct { artifactExchangeRunner ArtifactExchangeRunner rawSyncTenant string + frictionRunner *review.Runner rawSyncDeviceAuth RawSyncDeviceAuth rawSyncCustody RawSyncCustody rawSyncStatus RawSyncStatusReader @@ -555,6 +558,14 @@ func WithGenerateStreamFunc(f insight.GenerateStreamFunc) Option { } } +// WithFriction attaches the Friction Log review runner. A nil runner leaves +// the feature unavailable. +func WithFriction(runner *review.Runner) Option { + return func(s *Server) { + s.frictionRunner = runner + } +} + // WithInsightLogDrainTimeouts overrides SSE insight log stream drain timeouts. // Zero or negative values keep the production defaults. func WithInsightLogDrainTimeouts(drain, stopWait time.Duration) Option { diff --git a/internal/sync/engine.go b/internal/sync/engine.go index e0f7eec563..b681283387 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -3763,6 +3763,24 @@ func (e *Engine) resyncBuildLocked( time.Since(tInsights).Round(time.Millisecond), ) } + // Digest snapshots and Markdown may contain transcript text. Preserve + // them only when the rebuilt archive stores that content. + if copyDerivedText { + if err := newDB.CopyFrictionStateFrom(origPath); err != nil { + log.Printf("resync: copy friction state: %v", err) + stats.Aborted = true + stats.Warnings = append(stats.Warnings, + "friction state copy failed, aborting swap: "+err.Error(), + ) + newDB.Close() + removeTempDB(tempPath) + restoreSkipCache() + e.mu.Lock() + e.lastSyncStats = stats + e.mu.Unlock() + return stats, err + } + } // Copy model pricing so usage costs survive the swap. The // startup seed only runs once per daemon lifetime, so a diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index af693db13d..41f15574e8 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -25,6 +25,7 @@ import ( "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/dbtest" "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/money" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/sync" @@ -18112,3 +18113,64 @@ func TestSyncAllPreservesUnprovenClaudeMissingRows(t *testing.T) { }) } } + +func TestResyncAllPreservesFrictionDigests(t *testing.T) { + env := setupTestEnv(t) + content := testjsonl.NewSessionBuilder(). + AddClaudeUser(tsEarly, "Hello"). + AddClaudeAssistant(tsEarlyS5, "Hi there!"). + String() + env.writeClaudeSession(t, "test-proj", "friction-test.jsonl", content) + env.engine.SyncAll(t.Context(), nil) + before, err := env.db.FrictionSubjectsForDate(t.Context(), "2024-01-01", time.UTC, false) + require.NoError(t, err) + require.Len(t, before, 1, "fixture session must be eligible before the digest") + assert.Equal(t, "friction-test", before[0].SubjectID) + + require.NoError(t, env.db.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2024-01-01", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), + Markdown: []byte("# Friction Log — 2024-01-01\n"), MarkdownSHA256: "x", RunID: "r", + }, []db.FrictionDigestSubject{{SubjectID: "friction-test", Date: "2024-01-01", SubjectKind: friction.SubjectSession}}, nil)) + + stats := env.engine.ResyncAll(t.Context(), nil) + require.NotZero(t, stats.Synced) + + got, err := env.db.GetFrictionDigest(t.Context(), "2024-01-01") + require.NoError(t, err) + require.NotNil(t, got, "digest must survive a full resync") + assert.Equal(t, "# Friction Log — 2024-01-01\n", string(got.Markdown)) + subjects, err := env.db.FrictionSubjectsForDate(t.Context(), "2024-01-01", time.UTC, false) + require.NoError(t, err) + assert.Empty(t, subjects, "membership survives, so the session is not re-reviewed") +} + +func TestResyncAllUsageOnlyDropsFrictionDigestText(t *testing.T) { + env := setupTestEnv(t) + content := testjsonl.NewSessionBuilder(). + AddClaudeUser(tsEarly, "private prompt"). + AddClaudeAssistant(tsEarlyS5, "private reply"). + String() + env.writeClaudeSession(t, "test-proj", "friction-private.jsonl", content) + require.Equal(t, 1, env.engine.SyncAll(t.Context(), nil).Synced) + require.NoError(t, env.db.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2024-01-01", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte(`{"prompt":"private prompt"}`), SummaryJSON: []byte("{}"), + Markdown: []byte("private reply"), MarkdownSHA256: "x", RunID: "r", + }, nil, nil)) + + env.engine.Close() + env.db.SetArchiveContent(config.ArchiveContentUsage) + usageEngine := sync.NewEngine(t.Context(), env.db, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {env.claudeDir}}, + Machine: "local", ArchiveContent: config.ArchiveContentUsage, + }) + t.Cleanup(usageEngine.Close) + stats := usageEngine.ResyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "resync aborted: %v", stats.Warnings) + got, err := env.db.GetFrictionDigest(t.Context(), "2024-01-01") + require.NoError(t, err) + assert.Nil(t, got, "usage-only archive must not retain digest text") +} diff --git a/openapi.yaml b/openapi.yaml index 8346622032..05e15f0aa0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8159,6 +8159,8 @@ components: data_version: format: int64 type: integer + friction_available: + type: boolean insight_generation_available: type: boolean read_only: @@ -8173,6 +8175,7 @@ components: - build_date - insight_generation_available - session_stats_available + - friction_available - api_version - data_version type: object From 51454d863473028f3f022dfbfb6479d6c1629ed9 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 20 Oct 2026 17:59:39 -0500 Subject: [PATCH 2/2] test(friction): keep concurrency coverage on supported paths The poller runs each job synchronously, and local digest work shares the archive-write barrier with sync and resync. Tests that bypass these paths create interleavings the application cannot produce. Keep coverage for PostgreSQL builders and pushes, where those operations can overlap, and for the local resync barrier. --- cmd/agentsview/friction_schedule_test.go | 34 --------------- .../postgres/friction_review_pgtest_test.go | 42 +++++++------------ 2 files changed, 14 insertions(+), 62 deletions(-) diff --git a/cmd/agentsview/friction_schedule_test.go b/cmd/agentsview/friction_schedule_test.go index 615dd0c105..33116442fa 100644 --- a/cmd/agentsview/friction_schedule_test.go +++ b/cmd/agentsview/friction_schedule_test.go @@ -252,40 +252,6 @@ func TestFrictionExclusiveKeepsDaemonAlive(t *testing.T) { assert.True(t, ran) } -func TestSerialExclusive(t *testing.T) { - excl := serialExclusive() - inside := make(chan struct{}) - release := make(chan struct{}) - defer func() { - select { - case <-release: - default: - close(release) - } - }() - go func() { _ = excl(func() error { close(inside); <-release; return nil }) }() - <-inside - second := make(chan struct{}) - go func() { _ = excl(func() error { close(second); return nil }) }() - assert.Never(t, func() bool { - select { - case <-second: - return true - default: - return false - } - }, time.Second, 10*time.Millisecond, "second call entered while the first held the lock") - close(release) - require.Eventually(t, func() bool { - select { - case <-second: - return true - default: - return false - } - }, time.Second, 10*time.Millisecond, "second call never ran") -} - func TestHubFrictionDefaultsToUTCDates(t *testing.T) { t.Setenv("TZ", "Pacific/Honolulu") t.Setenv("AGENTSVIEW_FRICTION_TZ", "") diff --git a/internal/postgres/friction_review_pgtest_test.go b/internal/postgres/friction_review_pgtest_test.go index 368dd4034b..ecfa7af960 100644 --- a/internal/postgres/friction_review_pgtest_test.go +++ b/internal/postgres/friction_review_pgtest_test.go @@ -4,7 +4,6 @@ package postgres import ( "context" - "database/sql" "strings" "sync" "testing" @@ -41,37 +40,26 @@ func (s *pausedFrictionSelection) FrictionSubjectsForDate(ctx context.Context, d func TestFrictionConcurrentBuildsAfterActivityMoves(t *testing.T) { for _, tc := range []struct { - name, backend string + name string competingBuild bool }{ - {"sqlite builders", "sqlite", true}, - {"postgres builders", "postgres", true}, - {"postgres push without another builder", "postgres", false}, + {"postgres builders", true}, + {"postgres push without another builder", false}, } { t.Run(tc.name, func(t *testing.T) { local, hub := openFrictionParityStores(t) ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() - var firstStore, secondStore review.Store = local, local - var counts *sql.DB - if tc.backend == "postgres" { - other, err := NewStore(testPGURL(t), "agentsview_friction_digest_test", true) - require.NoError(t, err) - t.Cleanup(func() { _ = other.Close() }) - firstStore, secondStore, counts = hub, other, hub.DB() - } else { - var err error - counts, err = sql.Open("sqlite3", local.Path()) - require.NoError(t, err) - t.Cleanup(func() { _ = counts.Close() }) - } - paused := &pausedFrictionSelection{Store: firstStore, selected: make(chan struct{}), resume: make(chan struct{})} + other, err := NewStore(testPGURL(t), "agentsview_friction_digest_test", true) + require.NoError(t, err) + t.Cleanup(func() { _ = other.Close() }) + paused := &pausedFrictionSelection{Store: hub, selected: make(chan struct{}), resume: make(chan struct{})} var resumeOnce sync.Once resume := func() { resumeOnce.Do(func() { close(paused.resume) }) } defer resume() now := func() time.Time { return time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) } first := &review.Runner{Store: paused, Now: now} - second := &review.Runner{Store: secondStore, Now: now} + second := &review.Runner{Store: other, Now: now} done := make(chan struct{}) var firstReport review.Report var buildErr error @@ -103,11 +91,9 @@ func TestFrictionConcurrentBuildsAfterActivityMoves(t *testing.T) { Text: "changed after selection", Title: "updated", Fingerprint: "updated", RulesVersion: friction.RulesVersion}}, }, })) - if tc.backend == "postgres" { - syncer := &Sync{pg: hub.pg, local: local, machine: "machine", schema: "agentsview_friction_digest_test", schemaDone: true} - _, err = syncer.Push(ctx, true, nil) - require.NoError(t, err) - } + syncer := &Sync{pg: hub.pg, local: local, machine: "machine", schema: "agentsview_friction_digest_test", schemaDone: true} + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) buildSecond := func() { secondReport, err := second.BuildDate(ctx, "2026-09-16", review.BuildOptions{}) require.NoError(t, err) @@ -124,7 +110,7 @@ func TestFrictionConcurrentBuildsAfterActivityMoves(t *testing.T) { require.NoError(t, ctx.Err()) } require.True(t, firstReport.Written) - storedFirst, err := firstStore.GetFrictionDigest(ctx, "2026-09-15") + storedFirst, err := hub.GetFrictionDigest(ctx, "2026-09-15") require.NoError(t, err) require.NotNil(t, storedFirst) assert.Equal(t, 1, storedFirst.SessionsScanned) @@ -135,13 +121,13 @@ func TestFrictionConcurrentBuildsAfterActivityMoves(t *testing.T) { if !tc.competingBuild { buildSecond() } - storedSecond, err := secondStore.GetFrictionDigest(ctx, "2026-09-16") + storedSecond, err := other.GetFrictionDigest(ctx, "2026-09-16") require.NoError(t, err) require.NotNil(t, storedSecond) assert.Equal(t, 2, storedSecond.SessionsScanned) assert.Contains(t, string(storedSecond.Markdown), "changed after selection") var sessions, occurrences int - require.NoError(t, counts.QueryRowContext(ctx, `SELECT SUM(session_count), SUM(occurrence_count) FROM friction_patterns`).Scan(&sessions, &occurrences)) + require.NoError(t, hub.DB().QueryRowContext(ctx, `SELECT SUM(session_count), SUM(occurrence_count) FROM friction_patterns`).Scan(&sessions, &occurrences)) assert.Equal(t, 3, sessions, "each of the three reviewed sessions contributes once") assert.Equal(t, 3, occurrences) })