Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
piebald: "Piebald",
antigravity: "Antigravity",
"antigravity-cli": "Antigravity CLI",
shelley: "Shelley",
};
</script>

Expand Down
29 changes: 23 additions & 6 deletions internal/db/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -1175,11 +1175,10 @@ func (db *DB) MessageTokenFingerprint(sessionID string) (string, error) {

// MessageContentHashFingerprint returns an exact ordered fingerprint
// of per-message body content: ordinal, the stored content_length
// column, and a SHA-256 over the sanitized content. The parse-diff
// comparator uses it instead of the aggregate
// MessageContentFingerprint (sum/max/min of content_length, kept for
// the PG push fast-path), which cannot see equal-length body rewrites
// or per-message length changes whose aggregates collide.
// column, and a SHA-256 over the sanitized content. Parse-diff and PG
// push use it alongside the aggregate MessageContentFingerprint
// (sum/max/min of content_length), which cannot see equal-length body
// rewrites or per-message length changes whose aggregates collide.
func (db *DB) MessageContentHashFingerprint(sessionID string) (string, error) {
rows, err := db.getReader().Query(
`SELECT ordinal, content, content_length
Expand Down Expand Up @@ -1221,6 +1220,20 @@ func (db *DB) MessageContentHashFingerprint(sessionID string) (string, error) {
// (selectMessageCols coalesces the same way); without it a single
// imported NULL row would error here and abort the whole parse-diff run.
func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
return db.MessageRoleTimeFingerprintWithTimestampNormalizer(
sessionID, nil,
)
}

// MessageRoleTimeFingerprintWithTimestampNormalizer returns the same
// fingerprint as MessageRoleTimeFingerprint after applying normalizeTimestamp
// to each timestamp value. It lets callers compare against stores that preserve
// a different timestamp representation while keeping the query and field
// ordering identical to the raw parse-diff fingerprint.
func (db *DB) MessageRoleTimeFingerprintWithTimestampNormalizer(
sessionID string,
normalizeTimestamp func(string) string,
) (string, error) {
rows, err := db.getReader().Query(
`SELECT ordinal, role, COALESCE(timestamp, '')
FROM messages
Expand All @@ -1241,6 +1254,9 @@ func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
return "", err
}
role = SanitizeUTF8(role)
if normalizeTimestamp != nil {
timestamp = normalizeTimestamp(timestamp)
}
fmt.Fprintf(&b, "%d|%d:%s|%d:%s;",
ordinal, len(role), role, len(timestamp), timestamp)
}
Expand All @@ -1253,7 +1269,8 @@ func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
// has_tool_use, and a SHA-256 over the sanitized thinking_text. The
// parse-diff comparator uses it as a tier-1 fast path so a parser change
// confined to these columns still triggers the tier-2 row comparison.
// Not used by the PG push fast-path.
// PG push uses it with a PostgreSQL-side twin to avoid skipping
// metadata-only rewrites.
func (db *DB) MessageFlagsFingerprint(sessionID string) (string, error) {
rows, err := db.getReader().Query(
`SELECT ordinal, is_system, has_thinking, has_tool_use,
Expand Down
60 changes: 54 additions & 6 deletions internal/db/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -1323,20 +1323,49 @@ func (db *DB) GetSessionMessageCount(
return count, true
}

// GetSessionVersion returns the message count and file mtime
// for change detection in SSE watchers.
// SessionVersionMarker returns a compact marker for one or more
// version fields. Inputs are length-framed so adjacent fields cannot
// collide by concatenation.
func SessionVersionMarker(parts ...string) int64 {
const (
offset64 = uint64(14695981039346656037)
prime64 = uint64(1099511628211)
)
h := offset64
write := func(s string) {
for _, b := range []byte(s) {
h ^= uint64(b)
h *= prime64
}
}
for _, part := range parts {
write(fmt.Sprintf("%d:", len(part)))
write(part)
}
return int64(h)
}

// GetSessionVersion returns the message count and a compact version
// marker for change detection in SSE watchers.
func (db *DB) GetSessionVersion(
id string,
) (count int, fileMtime int64, ok bool) {
) (count int, version int64, ok bool) {
var fileMtime int64
var fileHash, localModifiedAt string
err := db.getReader().QueryRow(
"SELECT message_count, COALESCE(file_mtime, 0)"+
"SELECT message_count, COALESCE(file_mtime, 0),"+
" COALESCE(file_hash, ''), COALESCE(local_modified_at, '')"+
" FROM sessions WHERE id = ?",
id,
).Scan(&count, &fileMtime)
).Scan(&count, &fileMtime, &fileHash, &localModifiedAt)
if err != nil {
return 0, 0, false
}
return count, fileMtime, true
return count, SessionVersionMarker(
fmt.Sprintf("%d", fileMtime),
fileHash,
localModifiedAt,
), true
}

// IncrementalInfo holds the data needed for incremental
Expand Down Expand Up @@ -1517,6 +1546,25 @@ func (db *DB) GetFileInfoByPath(
return s.Int64, m.Int64, true
}

// GetFileHashByPath returns the stored file_hash for the session
// matching file_path, preferring the most recently modified row.
// The bool is false when no row exists or the column is NULL. Used
// by the Shelley skip to compare a per-conversation content
// fingerprint alongside file_mtime.
func (db *DB) GetFileHashByPath(path string) (hash string, ok bool) {
var h sql.NullString
err := db.getReader().QueryRow(
"SELECT file_hash FROM sessions"+
" WHERE file_path = ?"+
" ORDER BY file_mtime DESC LIMIT 1",
path,
).Scan(&h)
if err != nil {
return "", false
}
return h.String, h.Valid
}

// GetDataVersionByPath returns the minimum data_version for
// sessions matching a file_path. Returns 0 when no session
// exists for the path.
Expand Down
2 changes: 1 addition & 1 deletion internal/db/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type Store interface {
SecretFindingSource(ctx context.Context, f SecretFinding) (string, bool, error)

// SSE change detection.
GetSessionVersion(id string) (count int, fileMtime int64, ok bool)
GetSessionVersion(id string) (count int, version int64, ok bool)

// Metadata.
GetStats(ctx context.Context, excludeOneShot, excludeAutomated bool) (Stats, error)
Expand Down
24 changes: 17 additions & 7 deletions internal/duckdb/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,21 +399,31 @@ func (s *Store) GetChildSessions(ctx context.Context, parentID string) ([]db.Ses

func (s *Store) GetSessionVersion(id string) (int, int64, bool) {
var count int
var fileMtime sql.NullInt64
var fileHash sql.NullString
var updated any
err := s.duck.QueryRow(
`SELECT message_count, COALESCE(local_modified_at, ended_at, started_at, created_at)
`SELECT message_count, file_mtime, file_hash,
COALESCE(local_modified_at, ended_at, started_at, created_at)
FROM sessions WHERE id = ?`,
id,
).Scan(&count, &updated)
).Scan(&count, &fileMtime, &fileHash, &updated)
if err != nil {
return 0, 0, false
}
formatted := formatDBTime(updated)
var h int64
for _, c := range formatted {
h = h*31 + int64(c)
fileMtimePart := ""
if fileMtime.Valid {
fileMtimePart = fmt.Sprintf("%d", fileMtime.Int64)
}
return count, h, true
fileHashPart := ""
if fileHash.Valid {
fileHashPart = fileHash.String
}
return count, db.SessionVersionMarker(
fileMtimePart,
fileHashPart,
formatDBTime(updated),
), true
}

func (s *Store) GetStats(ctx context.Context, excludeOneShot, excludeAutomated bool) (db.Stats, error) {
Expand Down
Loading