Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

598 lines
18 KiB
Go

// Package savings persists cumulative token-savings metrics across server
// restarts. Every source-reading tool call feeds this store through the MCP
// server's tokenStats, so over time the numbers become a credible narrative:
// "Gortex saved N tokens / $X at model rate this month".
//
// Storage: the machine-global SQLite sidecar (~/.gortex/sidecar.sqlite —
// the same database that holds notes, memories, scopes, and notebooks).
// Every observation is one transaction (event row + totals upsert), so the
// ledger is durable at the call: a SIGKILLed server loses nothing, and
// multiple gortex processes write the same database safely through SQLite's
// WAL + busy-timeout. The flat-file era (savings.json cumulative totals +
// savings.jsonl event log under the cache dir) is imported once on open and
// the legacy files renamed to *.bak.
package savings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/platform"
)
// schemaVersion is the snapshot-shape version, kept for the JSON surface
// (graph_stats cumulative_savings, `gortex savings --json`) and for
// reading flat-file-era ledgers during legacy import.
const schemaVersion = 1
// Totals is the cumulative record for a single scope (top-level or per-repo).
type Totals struct {
TokensSaved int64 `json:"tokens_saved"`
TokensReturned int64 `json:"tokens_returned"`
CallsCounted int64 `json:"calls_counted"`
}
// File is the snapshot shape callers consume (graph_stats, the CLI) and
// the on-disk schema of the flat-file era — still parsed by the one-shot
// legacy import.
type File struct {
Version int `json:"version"`
FirstSeen time.Time `json:"first_seen"`
LastUpdated time.Time `json:"last_updated"`
Totals Totals `json:"totals"`
PerRepo map[string]*Totals `json:"per_repo,omitempty"`
PerLanguage map[string]*Totals `json:"per_language,omitempty"`
// DroppedObservations counts ledger writes this process discarded
// (accounting must never fail a tool call, but drops must not be
// invisible — a persistently failing ledger looks exactly like
// "nothing recorded" otherwise).
DroppedObservations int64 `json:"dropped_observations,omitempty"`
}
// Observation is one source-reading tool call to book.
type Observation struct {
Repo string
Language string
Tool string
SessionID string
// Model is the LLM model that drove the call when known (resolved by
// the recorder from the host's model hint); Client is the MCP client
// app from the initialize handshake. Both may be empty.
Model string
Client string
Returned int64
Saved int64
}
// Store is the token-savings ledger. All operations are safe for
// concurrent use. When opened with an empty path the store tracks
// in-memory only — the behaviour test fixtures and the eval servers
// rely on — and never touches disk.
//
// Write errors against the sidecar are intentionally not propagated to
// record() callers (accounting must never fail a tool call), but unlike
// the flat-file era there is no batching: an observation either commits
// durably or is dropped whole.
type Store struct {
mu sync.Mutex
sc *persistence.SidecarStore
mem File // in-memory accumulation when sc == nil
memEvents []Event // in-memory event log when sc == nil
// dropped counts observations the sidecar refused (disk full,
// permissions, lock timeout). warnOnce emits a single stderr line
// the first time it happens so the failure is diagnosable without
// failing any tool call.
dropped atomic.Int64
warnOnce sync.Once
}
// DefaultDBPath returns the canonical savings ledger location: the
// machine-global sidecar database under the Gortex data dir.
func DefaultDBPath() string {
return persistence.DefaultSidecarPath(platform.DataDir())
}
// DefaultPath returns the flat-file era's savings.json location under the
// Gortex cache dir. The live ledger no longer writes it — the path is the
// default source for the one-shot legacy import (see Store.ImportLegacy).
//
// An absolute $XDG_CACHE_HOME is honoured; otherwise the location stays
// under os.UserCacheDir() — the historical default for this store, kept
// so an existing savings file is not orphaned. Returns an empty string
// when no cache dir can be resolved.
func DefaultPath() string {
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if base, err := os.UserCacheDir(); err != nil || base == "" {
return ""
}
}
return filepath.Join(platform.OSCacheDir(), "savings.json")
}
// DefaultEventsPath returns the flat-file era's savings.jsonl event-log
// path next to DefaultPath. Empty when the cache dir is unavailable.
func DefaultEventsPath() string {
p := DefaultPath()
if p == "" {
return ""
}
return EventsPathFor(p)
}
// EventsPathFor returns the JSONL event-log path that corresponds to a
// flat-file cumulative savings JSON path — `<dir>/savings.jsonl` alongside
// the JSON file. Empty when storePath is empty.
func EventsPathFor(storePath string) string {
if storePath == "" {
return ""
}
dir := filepath.Dir(storePath)
base := filepath.Base(storePath)
ext := filepath.Ext(base)
stem := base
if ext != "" {
stem = base[:len(base)-len(ext)]
}
return filepath.Join(dir, stem+".jsonl")
}
// Open opens the savings ledger inside the sidecar database at dbPath
// (creating tables as needed). An empty dbPath yields an in-memory-only
// store. The sidecar handle is process-shared: opening the same path the
// notes/memories managers use reuses their connection.
func Open(dbPath string) (*Store, error) {
s := &Store{}
s.mem = emptyFile()
if dbPath == "" {
return s, nil
}
sc, err := persistence.OpenSidecar(dbPath)
if err != nil {
return nil, fmt.Errorf("open savings ledger: %w", err)
}
s.sc = sc
return s, nil
}
func emptyFile() File {
return File{
Version: schemaVersion,
PerRepo: make(map[string]*Totals),
PerLanguage: make(map[string]*Totals),
}
}
// AddObservation books one source-reading tool call. Durable immediately
// when the store is sidecar-backed; in-memory otherwise.
func (s *Store) AddObservation(o Observation) {
if s == nil {
return
}
if o.Saved < 0 {
o.Saved = 0
}
now := time.Now().UTC()
if s.sc != nil {
err := s.sc.AddSavingsObservation(persistence.SavingsEvent{
TS: now,
SessionID: o.SessionID,
Tool: o.Tool,
Repo: o.Repo,
Language: o.Language,
Model: o.Model,
Client: o.Client,
Returned: o.Returned,
Saved: o.Saved,
})
if err != nil {
s.dropped.Add(1)
s.warnOnce.Do(func() {
fmt.Fprintf(os.Stderr, "gortex: savings ledger write failed, observations will be dropped: %v\n", err)
})
}
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.mem.FirstSeen.IsZero() {
s.mem.FirstSeen = now
}
s.mem.LastUpdated = now
s.mem.Totals.TokensSaved += o.Saved
s.mem.Totals.TokensReturned += o.Returned
s.mem.Totals.CallsCounted++
addBucket := func(bucket map[string]*Totals, key string) {
if key == "" {
return
}
t := bucket[key]
if t == nil {
t = &Totals{}
bucket[key] = t
}
t.TokensSaved += o.Saved
t.TokensReturned += o.Returned
t.CallsCounted++
}
addBucket(s.mem.PerRepo, o.Repo)
addBucket(s.mem.PerLanguage, o.Language)
s.memEvents = append(s.memEvents, Event{
TS: now,
SessionID: o.SessionID,
Repo: o.Repo,
Language: o.Language,
Tool: o.Tool,
Model: o.Model,
Client: o.Client,
Returned: o.Returned,
Saved: o.Saved,
})
}
// Snapshot returns the current cumulative totals. Sidecar-backed stores
// read the live aggregates, so the snapshot reflects every writer process,
// not just this one. FirstSeen stays the zero time until something has
// actually been recorded — callers must not present it as "tracking since"
// when it is zero.
//
// On a read error the returned File is empty and the error is non-nil —
// callers that render the empty state must distinguish "nothing recorded"
// from "ledger unreadable".
func (s *Store) Snapshot() (File, error) {
if s == nil {
return emptyFile(), nil
}
if s.sc == nil {
s.mu.Lock()
defer s.mu.Unlock()
cp := s.mem
cp.PerRepo = copyTotalsMap(s.mem.PerRepo)
cp.PerLanguage = copyTotalsMap(s.mem.PerLanguage)
cp.DroppedObservations = s.dropped.Load()
return cp, nil
}
buckets, firstSeen, lastUpdated, err := s.sc.SavingsTotals()
if err != nil {
return emptyFile(), fmt.Errorf("savings totals read: %w", err)
}
out := emptyFile()
out.FirstSeen = firstSeen
out.LastUpdated = lastUpdated
out.DroppedObservations = s.dropped.Load()
for bucket, r := range buckets {
t := &Totals{TokensSaved: r.Saved, TokensReturned: r.Returned, CallsCounted: r.Calls}
switch {
case bucket == "":
out.Totals = *t
case len(bucket) > 5 && bucket[:5] == "repo:":
out.PerRepo[bucket[5:]] = t
case len(bucket) > 5 && bucket[:5] == "lang:":
out.PerLanguage[bucket[5:]] = t
}
}
return out, nil
}
// ToolTotals returns the per-tool aggregate over events with TS >= since
// (zero = all time), sorted by tokens saved descending. Sidecar-backed
// stores aggregate in SQL so the dashboard never materializes the full
// event history just to render a breakdown.
func (s *Store) ToolTotals(since time.Time) ([]ToolTotal, error) {
if s == nil {
return nil, nil
}
if s.sc == nil {
s.mu.Lock()
events := FilterSince(s.memEvents, since)
s.mu.Unlock()
_, per := AggregateByTool(events)
return per, nil
}
rows, err := s.sc.SavingsToolTotals(since)
if err != nil {
return nil, err
}
out := make([]ToolTotal, 0, len(rows))
for _, r := range rows {
out = append(out, ToolTotal{Tool: r.Tool, Totals: Totals{
TokensSaved: r.Saved,
TokensReturned: r.Returned,
CallsCounted: r.Calls,
}})
}
return out, nil
}
// Close releases the underlying sidecar handle (dropping it from the
// process-wide cache). Safe on nil and in-memory stores. The daemon
// keeps its store open for the process lifetime; tests and one-shot
// CLI paths should Close when done.
func (s *Store) Close() error {
if s == nil || s.sc == nil {
return nil
}
return s.sc.Close()
}
// EventsSince returns the per-call events with TS >= since, oldest first.
// since=zero returns the full event history.
func (s *Store) EventsSince(since time.Time) ([]Event, error) {
if s == nil {
return nil, nil
}
if s.sc == nil {
s.mu.Lock()
defer s.mu.Unlock()
return FilterSince(s.memEvents, since), nil
}
rows, err := s.sc.SavingsEventsSince(since)
if err != nil {
return nil, err
}
out := make([]Event, 0, len(rows))
for _, r := range rows {
out = append(out, Event{
TS: r.TS,
SessionID: r.SessionID,
Repo: r.Repo,
Language: r.Language,
Tool: r.Tool,
Model: r.Model,
Client: r.Client,
Returned: r.Returned,
Saved: r.Saved,
})
}
return out, nil
}
// ModelTotals returns the per-model aggregate over events with TS >= since
// (zero = all time), tokens-saved descending. Only calls with a resolved
// model are counted — clients that don't surface their model are absent.
func (s *Store) ModelTotals(since time.Time) ([]DimTotal, error) {
return s.dimTotals(since, AggregateByModel, func(t time.Time) ([]persistence.SavingsDimRow, error) {
return s.sc.SavingsModelTotals(t)
})
}
// ClientTotals returns the per-MCP-client aggregate over events with
// TS >= since (zero = all time), tokens-saved descending.
func (s *Store) ClientTotals(since time.Time) ([]DimTotal, error) {
return s.dimTotals(since, AggregateByClient, func(t time.Time) ([]persistence.SavingsDimRow, error) {
return s.sc.SavingsClientTotals(t)
})
}
// dimTotals is the shared model/client breakdown: aggregate in SQL when
// sidecar-backed, fold the in-memory event log otherwise.
func (s *Store) dimTotals(since time.Time, memAgg func([]Event) []DimTotal, scQuery func(time.Time) ([]persistence.SavingsDimRow, error)) ([]DimTotal, error) {
if s == nil {
return nil, nil
}
if s.sc == nil {
s.mu.Lock()
events := FilterSince(s.memEvents, since)
s.mu.Unlock()
return memAgg(events), nil
}
rows, err := scQuery(since)
if err != nil {
return nil, err
}
out := make([]DimTotal, 0, len(rows))
for _, r := range rows {
out = append(out, DimTotal{Name: r.Name, Totals: Totals{
TokensSaved: r.Saved,
TokensReturned: r.Returned,
CallsCounted: r.Calls,
}})
}
return out, nil
}
// Flush is a no-op kept for call-site compatibility: every observation
// commits durably as it is recorded, so there is nothing to flush.
func (s *Store) Flush() error { return nil }
// Reset wipes all cumulative data and events. Used by
// `gortex savings --reset`. The legacy-import mark survives, so flat
// files already imported (and renamed *.bak) are not re-imported.
func (s *Store) Reset() error {
if s == nil {
return nil
}
s.mu.Lock()
s.mem = emptyFile()
s.memEvents = nil
s.mu.Unlock()
if s.sc == nil {
return nil
}
return s.sc.ResetSavings()
}
// ImportLegacy imports the flat-file era's ledger — the cumulative
// savings.json at jsonPath and its sibling savings.jsonl — into the
// sidecar, then renames both files to *.bak. Idempotent: a migration
// mark guarantees the import runs once per sidecar, including when
// there was nothing to import. In-memory stores skip the import.
//
// The cumulative file was flush-batched while the event log appended
// eagerly, so a SIGKILL-era ledger can carry fewer totals than its own
// events; totals are floored per bucket and per field at what the
// events reconstruct, or "Last 7 days" could exceed "All time".
func (s *Store) ImportLegacy(jsonPath string) error {
if s == nil || s.sc == nil || jsonPath == "" {
return nil
}
eventsPath := EventsPathFor(jsonPath)
if s.sc.SavingsLegacyImportDone() {
// Self-heal lingering files: a crash between the import commit
// and the renames — or an old-version daemon recreating the
// files after migration — leaves live-looking flat files no
// future open would ever touch. Their content is intentionally
// not imported (the mark owns that decision); sweep them aside.
_ = renameLegacySavings(jsonPath)
_ = renameLegacySavings(eventsPath)
return nil
}
legacy, err := readLegacyFile(jsonPath)
if err != nil {
return err
}
// A hard read error must abort without marking or renaming so the
// import retries next open — importing a truncated event log and
// renaming the original away would make the loss permanent.
events, err := LoadEvents(eventsPath, time.Time{})
if err != nil {
return fmt.Errorf("read legacy savings events: %w", err)
}
// Rebuild totals from the events unconditionally...
rebuilt := make(map[string]persistence.SavingsTotalsRow)
for _, ev := range events {
bump := func(bucket string) {
r := rebuilt[bucket]
r.Saved += ev.Saved
r.Returned += ev.Returned
r.Calls++
rebuilt[bucket] = r
}
bump("")
if ev.Repo != "" {
bump("repo:" + ev.Repo)
}
if ev.Language != "" {
bump("lang:" + ev.Language)
}
}
// ...then overlay the cumulative file's (possibly larger, possibly
// flush-lagged) numbers, taking the per-field max of the two views.
buckets := rebuilt
var firstSeen, lastUpdated time.Time
if legacy != nil {
overlay := func(bucket string, t Totals) {
r := buckets[bucket]
r.Saved = max(r.Saved, t.TokensSaved)
r.Returned = max(r.Returned, t.TokensReturned)
r.Calls = max(r.Calls, t.CallsCounted)
buckets[bucket] = r
}
overlay("", legacy.Totals)
for k, v := range legacy.PerRepo {
overlay("repo:"+k, *v)
}
for k, v := range legacy.PerLanguage {
overlay("lang:"+k, *v)
}
firstSeen, lastUpdated = legacy.FirstSeen, legacy.LastUpdated
}
if len(events) > 0 {
if firstSeen.IsZero() || events[0].TS.Before(firstSeen) {
firstSeen = events[0].TS
}
if last := events[len(events)-1].TS; last.After(lastUpdated) {
lastUpdated = last
}
}
pevents := make([]persistence.SavingsEvent, 0, len(events))
for _, ev := range events {
pevents = append(pevents, persistence.SavingsEvent{
TS: ev.TS,
SessionID: ev.SessionID,
Tool: ev.Tool,
Repo: ev.Repo,
Language: ev.Language,
Returned: ev.Returned,
Saved: ev.Saved,
})
}
if err := s.sc.ImportLegacySavings(buckets, firstSeen, lastUpdated, pevents); err != nil {
return err
}
return errors.Join(
renameLegacySavings(jsonPath),
renameLegacySavings(eventsPath),
)
}
// renameLegacySavings moves an already-imported flat file aside to
// <file>.bak. Never deletes; a missing file is fine. A rename failure
// is reported so the caller can surface it — silently leaving the file
// in place makes it look live when it no longer is.
func renameLegacySavings(path string) error {
if path == "" {
return nil
}
if _, err := os.Stat(path); err != nil {
return nil
}
if err := os.Rename(path, path+".bak"); err != nil {
return fmt.Errorf("rename legacy savings file: %w", err)
}
return nil
}
// readLegacyFile loads a flat-file era savings.json. Returns (nil, nil)
// when the file doesn't exist; corrupt or version-mismatched files are
// skipped the same way (the import has nothing trustworthy to carry over).
func readLegacyFile(path string) (*File, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read legacy savings: %w", err)
}
var loaded File
if jerr := json.Unmarshal(data, &loaded); jerr != nil || loaded.Version != schemaVersion {
return nil, nil
}
if loaded.PerRepo == nil {
loaded.PerRepo = make(map[string]*Totals)
}
if loaded.PerLanguage == nil {
loaded.PerLanguage = make(map[string]*Totals)
}
// JSON null map values unmarshal to nil pointers — drop them here,
// the single choke point, so no consumer dereferences one.
for k, v := range loaded.PerRepo {
if v == nil {
delete(loaded.PerRepo, k)
}
}
for k, v := range loaded.PerLanguage {
if v == nil {
delete(loaded.PerLanguage, k)
}
}
return &loaded, nil
}
// copyTotalsMap returns a deep copy of a name → Totals map.
func copyTotalsMap(src map[string]*Totals) map[string]*Totals {
if src == nil {
return make(map[string]*Totals)
}
dst := make(map[string]*Totals, len(src))
for k, v := range src {
cp := *v
dst[k] = &cp
}
return dst
}