Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions internal/providers/crush/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,10 @@ func querySessions(ctx context.Context, dbPath string) ([]crushSession, error) {
promptTokens sql.NullInt64
complTokens sql.NullInt64
cost sql.NullFloat64
createdMS sql.NullInt64
updatedMS sql.NullInt64
createdAt sql.NullInt64
updatedAt sql.NullInt64
)
if err := rows.Scan(&id, &msgCount, &promptTokens, &complTokens, &cost, &createdMS, &updatedMS); err != nil {
if err := rows.Scan(&id, &msgCount, &promptTokens, &complTokens, &cost, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("crush: scanning session row: %w", err)
}

Expand All @@ -123,8 +123,8 @@ func querySessions(ctx context.Context, dbPath string) ([]crushSession, error) {
MessageCount: nonNegativeInt64(msgCount),
PromptTokens: nonNegativeInt64(promptTokens),
CompletionTokens: nonNegativeInt64(complTokens),
CreatedAt: millisToTime(createdMS),
UpdatedAt: millisToTime(updatedMS),
CreatedAt: unixSecondsToTime(createdAt),
UpdatedAt: unixSecondsToTime(updatedAt),
}
if cost.Valid && cost.Float64 > 0 {
s.Cost = cost.Float64
Expand Down Expand Up @@ -177,14 +177,20 @@ func latestAssistantModel(ctx context.Context, db *sql.DB, sessionID string, has
return strings.TrimSpace(model.String), strings.TrimSpace(provider.String), nil
}

// millisToTime converts a nullable Unix-milliseconds column into a UTC
// unixSecondsToTime converts a nullable Unix-seconds column into a UTC
// time.Time. NULL or zero values return the zero time; callers must
// check before formatting day buckets.
func millisToTime(v sql.NullInt64) time.Time {
//
// Despite the "milliseconds" claim in Crush's initial migration comment,
// the stored values are seconds: the update_sessions_updated_at trigger
// writes strftime('%s','now') and the Crush CLI renders
// time.Unix(CreatedAt, 0). Verified against real Crush DBs (see
// TestQuerySessions_TimestampsAreUnixSeconds).
func unixSecondsToTime(v sql.NullInt64) time.Time {
if !v.Valid || v.Int64 <= 0 {
return time.Time{}
}
return time.UnixMilli(v.Int64).UTC()
return time.Unix(v.Int64, 0).UTC()
}

func nonNegativeInt64(v sql.NullInt64) int64 {
Expand Down
84 changes: 84 additions & 0 deletions internal/providers/crush/query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package crush

import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"

_ "github.com/mattn/go-sqlite3"
)

// TestQuerySessions_TimestampsAreUnixSeconds pins that sessions.created_at
// and sessions.updated_at are Unix SECONDS, not milliseconds.
//
// Crush's initial migration comment claims milliseconds, but the values
// written are seconds: the update_sessions_updated_at trigger stores
// strftime('%s','now') and the Crush CLI renders time.Unix(CreatedAt, 0)
// (see charmbracelet/crush internal/db/migrations and internal/session).
// 1784269138 is a real captured value: 2026-07-17 as seconds, but
// 1970-01-21 if misread as milliseconds — which is exactly how this bug
// shipped (sessions silently bucketed into January 1970).
func TestQuerySessions_TimestampsAreUnixSeconds(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "crush.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open fixture db: %v", err)
}
defer db.Close()

schema := `
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
parent_session_id TEXT,
message_count INTEGER DEFAULT 0,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
cost REAL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
model TEXT,
created_at INTEGER
);`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("create schema: %v", err)
}
if _, err := db.Exec(
`INSERT INTO sessions (id, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at)
VALUES ('sess-1', 3, 100, 200, 0.5, ?, ?)`,
int64(1784269138), int64(1784269500),
); err != nil {
t.Fatalf("seed session: %v", err)
}

sessions, err := querySessions(context.Background(), dbPath)
if err != nil {
t.Fatalf("querySessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("got %d sessions, want 1", len(sessions))
}

want := time.Unix(1784269138, 0).UTC()
got := sessions[0].CreatedAt
if !got.Equal(want) {
t.Errorf("CreatedAt = %v, want %v", got, want)
}
if got.Year() < 2020 {
t.Errorf("CreatedAt year = %d, dates landed before 2020 (millisecond misread?); got %v", got.Year(), got)
}
if got.UTC().Format(time.DateOnly) != "2026-07-17" {
t.Errorf("CreatedAt date = %s, want 2026-07-17", got.UTC().Format(time.DateOnly))
}

wantUpdated := time.Unix(1784269500, 0).UTC()
if !sessions[0].UpdatedAt.Equal(wantUpdated) {
t.Errorf("UpdatedAt = %v, want %v", sessions[0].UpdatedAt, wantUpdated)
}
}