diff --git a/cmd/agentsview/friction_schedule.go b/cmd/agentsview/friction_schedule.go new file mode 100644 index 000000000..a0323b556 --- /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 000000000..33116442f --- /dev/null +++ b/cmd/agentsview/friction_schedule_test.go @@ -0,0 +1,279 @@ +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 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 2bb8adb63..237ef4012 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 7a25dfd4a..6ad555d4f 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 d64b3e16b..22f932503 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 a00cb8814..51305db14 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 1c8e8304f..fbddabe68 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 65af6a041..82e05db46 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 09983209e..2941bdb25 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 16f32e203..0c11a8ee1 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 4358c66dd..b09ca4689 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 b74111aba..fd1503380 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 6784e3941..32488ae18 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 6e99b0bc5..bed0b87ef 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 dd3621c49..c3f65d723 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 2059b4ba3..c2fb0f07a 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 ece81bc7b..1470f182a 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 3e3fde7cb..d92af7b59 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 000000000..e27cdaae9 --- /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 000000000..50712ef76 --- /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 000000000..52a84c6d4 --- /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 75f6f53a9..d73f91bef 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 000000000..dd8b99f95 --- /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 000000000..789d0e145 --- /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 000000000..fbde38685 --- /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 000000000..6910f6695 --- /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 000000000..f82422def --- /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 000000000..c5ebd2afa --- /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 000000000..8c1aac146 --- /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 000000000..457f30e45 --- /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 000000000..8de699bfe --- /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 000000000..0fa80b421 --- /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 e1ddc74a7..c4dfba96a 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 000000000..869beb34b --- /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 000000000..38da3c361 --- /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 000000000..ecfa7af96 --- /dev/null +++ b/internal/postgres/friction_review_pgtest_test.go @@ -0,0 +1,246 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "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 string + competingBuild bool + }{ + {"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() + 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: other, 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}}, + }, + })) + 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 := hub.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 := 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, 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) + }) + } +} + +// 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 70be3aad5..77025f85e 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 e901865ad..ccd826193 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 70661d866..151d91137 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 697e864b9..de0744844 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 5b256155f..e314f19f8 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 000000000..8707f2ff1 --- /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 6274b5b66..0b0f3b00a 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 b3b1b8fcf..1c37a4bed 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 72e0c8aa4..19176743d 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 b5fe6c748..09877e550 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 5ba650199..392c8b485 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 9f144228f..033112f22 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 22c3021d4..5ddf21c48 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 e0f7eec56..b68128338 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 af693db13..41f15574e 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 834662203..05e15f0aa 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