Skip to content

Commit e0b062f

Browse files
committed
fix(sessionwatch): detect file hash rewrites
1 parent 31bbe02 commit e0b062f

7 files changed

Lines changed: 162 additions & 45 deletions

File tree

internal/db/sessions.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,20 +1323,49 @@ func (db *DB) GetSessionMessageCount(
13231323
return count, true
13241324
}
13251325

1326-
// GetSessionVersion returns the message count and file mtime
1327-
// for change detection in SSE watchers.
1326+
// SessionVersionMarker returns a compact marker for one or more
1327+
// version fields. Inputs are length-framed so adjacent fields cannot
1328+
// collide by concatenation.
1329+
func SessionVersionMarker(parts ...string) int64 {
1330+
const (
1331+
offset64 = uint64(14695981039346656037)
1332+
prime64 = uint64(1099511628211)
1333+
)
1334+
h := offset64
1335+
write := func(s string) {
1336+
for _, b := range []byte(s) {
1337+
h ^= uint64(b)
1338+
h *= prime64
1339+
}
1340+
}
1341+
for _, part := range parts {
1342+
write(fmt.Sprintf("%d:", len(part)))
1343+
write(part)
1344+
}
1345+
return int64(h)
1346+
}
1347+
1348+
// GetSessionVersion returns the message count and a compact version
1349+
// marker for change detection in SSE watchers.
13281350
func (db *DB) GetSessionVersion(
13291351
id string,
1330-
) (count int, fileMtime int64, ok bool) {
1352+
) (count int, version int64, ok bool) {
1353+
var fileMtime int64
1354+
var fileHash, localModifiedAt string
13311355
err := db.getReader().QueryRow(
1332-
"SELECT message_count, COALESCE(file_mtime, 0)"+
1356+
"SELECT message_count, COALESCE(file_mtime, 0),"+
1357+
" COALESCE(file_hash, ''), COALESCE(local_modified_at, '')"+
13331358
" FROM sessions WHERE id = ?",
13341359
id,
1335-
).Scan(&count, &fileMtime)
1360+
).Scan(&count, &fileMtime, &fileHash, &localModifiedAt)
13361361
if err != nil {
13371362
return 0, 0, false
13381363
}
1339-
return count, fileMtime, true
1364+
return count, SessionVersionMarker(
1365+
fmt.Sprintf("%d", fileMtime),
1366+
fileHash,
1367+
localModifiedAt,
1368+
), true
13401369
}
13411370

13421371
// IncrementalInfo holds the data needed for incremental

internal/db/store.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ type Store interface {
4545
SecretFindingSource(ctx context.Context, f SecretFinding) (string, bool, error)
4646

4747
// SSE change detection.
48-
GetSessionVersion(id string) (count int, fileMtime int64, ok bool)
48+
GetSessionVersion(id string) (count int, version int64, ok bool)
4949

5050
// Metadata.
5151
GetStats(ctx context.Context, excludeOneShot, excludeAutomated bool) (Stats, error)

internal/duckdb/store.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -399,21 +399,31 @@ func (s *Store) GetChildSessions(ctx context.Context, parentID string) ([]db.Ses
399399

400400
func (s *Store) GetSessionVersion(id string) (int, int64, bool) {
401401
var count int
402+
var fileMtime sql.NullInt64
403+
var fileHash sql.NullString
402404
var updated any
403405
err := s.duck.QueryRow(
404-
`SELECT message_count, COALESCE(local_modified_at, ended_at, started_at, created_at)
406+
`SELECT message_count, file_mtime, file_hash,
407+
COALESCE(local_modified_at, ended_at, started_at, created_at)
405408
FROM sessions WHERE id = ?`,
406409
id,
407-
).Scan(&count, &updated)
410+
).Scan(&count, &fileMtime, &fileHash, &updated)
408411
if err != nil {
409412
return 0, 0, false
410413
}
411-
formatted := formatDBTime(updated)
412-
var h int64
413-
for _, c := range formatted {
414-
h = h*31 + int64(c)
414+
fileMtimePart := ""
415+
if fileMtime.Valid {
416+
fileMtimePart = fmt.Sprintf("%d", fileMtime.Int64)
415417
}
416-
return count, h, true
418+
fileHashPart := ""
419+
if fileHash.Valid {
420+
fileHashPart = fileHash.String
421+
}
422+
return count, db.SessionVersionMarker(
423+
fileMtimePart,
424+
fileHashPart,
425+
formatDBTime(updated),
426+
), true
417427
}
418428

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

internal/postgres/store.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ func (s *Store) SetCursorSecret(secret []byte) {
5454
// and pins) is writable through dedicated methods.
5555
func (s *Store) ReadOnly() bool { return true }
5656

57-
// GetSessionVersion returns the message count and a hash of
58-
// updated_at for SSE change detection.
57+
// GetSessionVersion returns the message count and a compact version
58+
// marker for SSE change detection.
5959
func (s *Store) GetSessionVersion(
6060
id string,
6161
) (int, int64, bool) {
@@ -69,12 +69,7 @@ func (s *Store) GetSessionVersion(
6969
if err != nil {
7070
return 0, 0, false
7171
}
72-
formatted := FormatISO8601(updatedAt)
73-
var h int64
74-
for _, c := range formatted {
75-
h = h*31 + int64(c)
76-
}
77-
return count, h, true
72+
return count, db.SessionVersionMarker(FormatISO8601(updatedAt)), true
7873
}
7974

8075
// ------------------------------------------------------------

internal/sessionwatch/watcher.go

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func New(d db.Store, engine *sync.Engine) *Watcher {
4343
}
4444

4545
// Events polls the database for session changes and signals the
46-
// returned channel when the message count changes. This is
46+
// returned channel when the session version changes. This is
4747
// decoupled from file I/O — the file watcher handles syncing
4848
// files to the database, and this monitor detects the resulting
4949
// DB changes.
@@ -60,15 +60,15 @@ func (w *Watcher) Events(
6060
defer close(ch)
6161

6262
// Seed initial state from the database.
63-
lastCount, lastDBMtime, _ := w.db.GetSessionVersion(
63+
lastCount, lastDBVersion, _ := w.db.GetSessionVersion(
6464
sessionID,
6565
)
6666

6767
if w.engine == nil {
6868
// PG read mode: poll GetSessionVersion only,
6969
// no file watching or fallback sync.
7070
w.pollDBOnly(ctx, ch, sessionID,
71-
lastCount, lastDBMtime)
71+
lastCount, lastDBVersion)
7272
return
7373
}
7474

@@ -91,7 +91,7 @@ func (w *Watcher) Events(
9191
changed := w.checkDBForChanges(
9292
sessionID,
9393
&lastCount,
94-
&lastDBMtime,
94+
&lastDBVersion,
9595
&sourcePath,
9696
&lastFileMtime,
9797
&fileMtimeChangedAt,
@@ -114,7 +114,7 @@ func (w *Watcher) Events(
114114
// no sync engine or file watcher.
115115
func (w *Watcher) pollDBOnly(
116116
ctx context.Context, ch chan<- struct{},
117-
sessionID string, lastCount int, lastDBMtime int64,
117+
sessionID string, lastCount int, lastDBVersion int64,
118118
) {
119119
ticker := time.NewTicker(PollInterval)
120120
defer ticker.Stop()
@@ -124,10 +124,10 @@ func (w *Watcher) pollDBOnly(
124124
case <-ctx.Done():
125125
return
126126
case <-ticker.C:
127-
count, dbMtime, ok := w.db.GetSessionVersion(sessionID)
128-
if ok && (count != lastCount || dbMtime != lastDBMtime) {
127+
count, dbVersion, ok := w.db.GetSessionVersion(sessionID)
128+
if ok && (count != lastCount || dbVersion != lastDBVersion) {
129129
lastCount = count
130-
lastDBMtime = dbMtime
130+
lastDBVersion = dbVersion
131131
select {
132132
case ch <- struct{}{}:
133133
case <-ctx.Done():
@@ -138,28 +138,25 @@ func (w *Watcher) pollDBOnly(
138138
}
139139
}
140140

141-
// checkDBForChanges polls the database for a session's
142-
// message_count and file_mtime. If either changed, it
143-
// returns true. As a fallback, it monitors source file
144-
// mtime and triggers a direct sync when the watcher
145-
// hasn't updated the DB.
141+
// checkDBForChanges polls the database for a session version change.
142+
// As a fallback, it monitors source file mtime and triggers a direct
143+
// sync when the watcher hasn't updated the DB.
146144
func (w *Watcher) checkDBForChanges(
147145
sessionID string,
148146
lastCount *int,
149-
lastDBMtime *int64,
147+
lastDBVersion *int64,
150148
sourcePath *string,
151149
lastFileMtime *int64,
152150
fileMtimeChangedAt *time.Time,
153151
) bool {
154-
// Primary: check if the DB has new data (message count
155-
// or file_mtime changed, covering both message appends
156-
// and metadata-only updates like progress events).
157-
if count, dbMtime, ok := w.db.GetSessionVersion(
152+
// Primary: check if the DB has new data. The version marker covers
153+
// message appends and metadata/content-only updates.
154+
if count, dbVersion, ok := w.db.GetSessionVersion(
158155
sessionID,
159156
); ok && (count != *lastCount ||
160-
dbMtime != *lastDBMtime) {
157+
dbVersion != *lastDBVersion) {
161158
*lastCount = count
162-
*lastDBMtime = dbMtime
159+
*lastDBVersion = dbVersion
163160
// DB was updated; clear any pending fallback.
164161
*fileMtimeChangedAt = time.Time{}
165162
return true
@@ -208,12 +205,12 @@ func (w *Watcher) checkDBForChanges(
208205
return false
209206
}
210207
// Re-check the DB after syncing.
211-
if count, dbMtime, ok := w.db.GetSessionVersion(
208+
if count, dbVersion, ok := w.db.GetSessionVersion(
212209
sessionID,
213210
); ok && (count != *lastCount ||
214-
dbMtime != *lastDBMtime) {
211+
dbVersion != *lastDBVersion) {
215212
*lastCount = count
216-
*lastDBMtime = dbMtime
213+
*lastDBVersion = dbVersion
217214
return true
218215
}
219216
}

internal/sessionwatch/watcher_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
1111
"go.kenn.io/agentsview/internal/db"
12+
"go.kenn.io/agentsview/internal/dbtest"
1213
"go.kenn.io/agentsview/internal/parser"
1314
"go.kenn.io/agentsview/internal/sync"
1415
)
@@ -71,3 +72,44 @@ func TestCheckDBForChanges_FileDisappears(t *testing.T) {
7172
assert.Empty(t, path)
7273
assert.Equal(t, int64(0), lastMtime)
7374
}
75+
76+
func TestCheckDBForChanges_FileHashChange(t *testing.T) {
77+
t.Parallel()
78+
w := testWatcher(t)
79+
database, ok := w.db.(*db.DB)
80+
require.True(t, ok, "test watcher should use SQLite DB")
81+
82+
const sessionID = "hash-change"
83+
var mtime int64 = 12345
84+
hash1 := "shelley-fingerprint-1"
85+
dbtest.SeedSession(t, database, sessionID, "proj", func(s *db.Session) {
86+
s.MessageCount = 2
87+
s.FileMtime = &mtime
88+
s.FileHash = &hash1
89+
})
90+
91+
lastCount, lastDBMtime, ok := w.db.GetSessionVersion(sessionID)
92+
require.True(t, ok, "initial session version")
93+
94+
hash2 := "shelley-fingerprint-2"
95+
dbtest.SeedSession(t, database, sessionID, "proj", func(s *db.Session) {
96+
s.MessageCount = 2
97+
s.FileMtime = &mtime
98+
s.FileHash = &hash2
99+
})
100+
101+
sourcePath := ""
102+
var lastFileMtime int64
103+
var mchanged time.Time
104+
changed := w.checkDBForChanges(
105+
sessionID,
106+
&lastCount,
107+
&lastDBMtime,
108+
&sourcePath,
109+
&lastFileMtime,
110+
&mchanged,
111+
)
112+
113+
assert.True(t, changed,
114+
"file_hash-only rewrites must refresh session watchers")
115+
}

internal/sync/shelley_integration_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ func newShelleyEngine(t *testing.T, dir string) (*sync.Engine, *db.DB) {
114114
return engine, database
115115
}
116116

117+
func sessionIDs(sessions []db.Session) []string {
118+
ids := make([]string, 0, len(sessions))
119+
for _, s := range sessions {
120+
ids = append(ids, s.ID)
121+
}
122+
return ids
123+
}
124+
117125
func mainConvoMsgs() []shelleyMsg {
118126
return []shelleyMsg{
119127
{1, "user",
@@ -357,10 +365,20 @@ func TestSyncShelleySameSecondInPlaceRewrite(t *testing.T) {
357365
engine, database := newShelleyEngine(t, dir)
358366
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
359367
assertMessageContent(t, database, "shelley:cMAIN1", "q", "partial")
368+
before, err := database.GetSessionFull(
369+
context.Background(), "shelley:cMAIN1",
370+
)
371+
require.NoError(t, err, "GetSessionFull before rewrite")
372+
require.NotNil(t, before, "session before rewrite")
373+
require.NotNil(t, before.FileMtime, "file_mtime before rewrite")
374+
require.NotNil(t, before.FileHash, "file_hash before rewrite")
375+
require.NotNil(t, before.LocalModifiedAt,
376+
"local_modified_at before rewrite")
360377

361378
// Rewrite the agent message in place. Crucially, updated_at is left
362379
// untouched (same second) and sequence_id is unchanged, so only the
363380
// content fingerprint differs.
381+
time.Sleep(20 * time.Millisecond)
364382
conn, err := sql.Open("sqlite3", dbPath)
365383
require.NoError(t, err)
366384
_, err = conn.Exec(
@@ -376,6 +394,32 @@ func TestSyncShelleySameSecondInPlaceRewrite(t *testing.T) {
376394
assertMessageContent(
377395
t, database, "shelley:cMAIN1", "q", "the full streamed answer",
378396
)
397+
after, err := database.GetSessionFull(
398+
context.Background(), "shelley:cMAIN1",
399+
)
400+
require.NoError(t, err, "GetSessionFull after rewrite")
401+
require.NotNil(t, after, "session after rewrite")
402+
require.NotNil(t, after.FileMtime, "file_mtime after rewrite")
403+
require.NotNil(t, after.FileHash, "file_hash after rewrite")
404+
require.NotNil(t, after.LocalModifiedAt,
405+
"local_modified_at after rewrite")
406+
assert.Equal(t, *before.FileMtime, *after.FileMtime,
407+
"same-second rewrite keeps the real Shelley updated_at timestamp")
408+
assert.NotEqual(t, *before.FileHash, *after.FileHash,
409+
"same-count same-second rewrite changes the content fingerprint")
410+
assert.Greater(t, *after.LocalModifiedAt, *before.LocalModifiedAt,
411+
"successful rewrite must bump local_modified_at for push windows")
412+
413+
candidates, err := database.ListSessionsModifiedBetween(
414+
context.Background(),
415+
*before.LocalModifiedAt,
416+
time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano),
417+
nil,
418+
nil,
419+
)
420+
require.NoError(t, err, "ListSessionsModifiedBetween after rewrite")
421+
assert.Contains(t, sessionIDs(candidates), "shelley:cMAIN1",
422+
"local_modified_at must select the rewritten session for pushes")
379423
}
380424

381425
// TestSyncShelleyLengthPreservingRewrite is the case a byte-length signal

0 commit comments

Comments
 (0)