Skip to content

Commit cd4edb2

Browse files
committed
fix(sync): require Windsurf hash freshness
VALID (fixed): kenn-io#1 -- Windsurf same-size/same-mtime SQLite rewrites need to compare the provider file_hash before taking skip-cache or stored-freshness shortcuts. Windsurf now uses a component-labeled content digest for state.vscdb, WAL/SHM, and workspace.json so the fingerprint is stable across roots while still moving when parsed source content changes. The sync freshness allowlist now requires that hash for Windsurf, matching the composite fingerprint contract. The commit also includes the repository hook's automatic Qoder slices.Backward modernization; without staging that generated fix, the pre-commit hook re-applies it and aborts before the Windsurf fix can be committed.
1 parent 439a9a4 commit cd4edb2

5 files changed

Lines changed: 165 additions & 8 deletions

File tree

internal/parser/qoder.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"slices"
89
"sort"
910
"strings"
1011
)
@@ -165,9 +166,9 @@ func DecodeQoderProjectDir(encoded string) string {
165166
}
166167
}
167168
}
168-
for i := len(parts) - 1; i >= 0; i-- {
169-
if parts[i] != "" {
170-
return NormalizeName(parts[i])
169+
for _, v := range slices.Backward(parts) {
170+
if v != "" {
171+
return NormalizeName(v)
171172
}
172173
}
173174
return NormalizeName(encoded)

internal/parser/windsurf_provider.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -814,15 +814,23 @@ func windsurfWorkspaceProject(dbPath string) string {
814814

815815
func windsurfSourceHash(dbPath, workspacePath string) (string, error) {
816816
h := sha256.New()
817-
for _, path := range []string{dbPath, dbPath + "-wal", dbPath + "-shm", workspacePath} {
818-
if path == "" || !IsRegularFile(path) {
817+
for _, component := range []struct {
818+
label string
819+
path string
820+
}{
821+
{label: "db", path: dbPath},
822+
{label: "wal", path: dbPath + "-wal"},
823+
{label: "shm", path: dbPath + "-shm"},
824+
{label: "workspace", path: workspacePath},
825+
} {
826+
if component.path == "" || !IsRegularFile(component.path) {
819827
continue
820828
}
821-
hash, err := hashJSONLSourceFile(path)
829+
hash, err := hashJSONLSourceFile(component.path)
822830
if err != nil {
823831
return "", err
824832
}
825-
_, _ = h.Write([]byte(path))
833+
_, _ = h.Write([]byte(component.label))
826834
_, _ = h.Write([]byte{0})
827835
_, _ = h.Write([]byte(hash))
828836
_, _ = h.Write([]byte{0})

internal/parser/windsurf_provider_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@ func TestWindsurfProviderDiscoversAndParsesWorkspaceSQLiteChat(t *testing.T) {
5858
assert.Equal(t, "Use the existing parser.", result.Messages[1].Content)
5959
}
6060

61+
func TestWindsurfProviderFingerprintHashIsContentBased(t *testing.T) {
62+
payload := windsurfVSCodeSessionJSON(
63+
"windsurf-session-hash",
64+
"Hash this",
65+
"Same content.",
66+
)
67+
rootA, _ := windsurfProviderFixture(t, payload)
68+
rootB, _ := windsurfProviderFixture(t, payload)
69+
providerA := newTestWindsurfProvider(rootA)
70+
providerB := newTestWindsurfProvider(rootB)
71+
72+
sourcesA, err := providerA.Discover(context.Background())
73+
require.NoError(t, err)
74+
require.Len(t, sourcesA, 1)
75+
sourcesB, err := providerB.Discover(context.Background())
76+
require.NoError(t, err)
77+
require.Len(t, sourcesB, 1)
78+
fpA, err := providerA.Fingerprint(context.Background(), sourcesA[0])
79+
require.NoError(t, err)
80+
fpB, err := providerB.Fingerprint(context.Background(), sourcesB[0])
81+
require.NoError(t, err)
82+
83+
require.NotEmpty(t, fpA.Hash)
84+
assert.Equal(t, fpA.Hash, fpB.Hash)
85+
}
86+
6187
func TestWindsurfProviderParsesTabContainerChatData(t *testing.T) {
6288
root, _ := windsurfProviderFixture(t, `{
6389
"tabs": [{

internal/sync/engine.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4197,7 +4197,7 @@ func providerProcessCacheKeyWithHash(
41974197

41984198
func providerFingerprintHashRequiredForFreshness(agent parser.AgentType) bool {
41994199
switch agent {
4200-
case parser.AgentDevin, parser.AgentQoder:
4200+
case parser.AgentDevin, parser.AgentQoder, parser.AgentWindsurf:
42014201
return true
42024202
default:
42034203
return false

internal/sync/windsurf_integration_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,85 @@ func TestSourceMtimeWindsurfUsesProviderFingerprint(t *testing.T) {
5555
assert.Greater(t, after, before)
5656
}
5757

58+
func TestProcessFileWindsurfSameMtimeHashChangeReparses(t *testing.T) {
59+
for _, tt := range []struct {
60+
name string
61+
seedCache bool
62+
freshSync bool
63+
}{
64+
{name: "skip cache", seedCache: true},
65+
{name: "db freshness", freshSync: true},
66+
} {
67+
t.Run(tt.name, func(t *testing.T) {
68+
root := filepath.Join(t.TempDir(), "Windsurf", "User")
69+
workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash")
70+
manifestPath := filepath.Join(workspaceDir, "workspace.json")
71+
dbPath := filepath.Join(workspaceDir, "state.vscdb")
72+
require.NoError(t, os.MkdirAll(workspaceDir, 0o755))
73+
require.NoError(t, os.WriteFile(manifestPath, []byte(`{"folder":"file:///work/demo"}`), 0o644))
74+
writeSyncWindsurfStateDB(t, dbPath, windsurfSyncPayload("hash-session", "Alpha reply"))
75+
virtualPath := dbPath + "#hash-session"
76+
database := dbtest.OpenTestDB(t)
77+
engine := NewEngine(database, EngineConfig{
78+
AgentDirs: map[parser.AgentType][]string{
79+
parser.AgentWindsurf: {root},
80+
},
81+
Machine: "devbox",
82+
})
83+
defer engine.Close()
84+
85+
first := engine.processFile(context.Background(), parser.DiscoveredFile{
86+
Path: virtualPath,
87+
Agent: parser.AgentWindsurf,
88+
})
89+
require.NoError(t, first.err)
90+
require.Len(t, first.results, 1)
91+
require.Len(t, first.results[0].Messages, 2)
92+
assert.Equal(t, "Alpha reply", first.results[0].Messages[1].Content)
93+
initialMtime := first.results[0].Session.File.Mtime
94+
initialHash := first.results[0].Session.File.Hash
95+
require.NotZero(t, initialMtime)
96+
require.NotEmpty(t, initialHash)
97+
writeSyncWindsurfResult(t, engine, first)
98+
99+
infoBefore, err := os.Stat(dbPath)
100+
require.NoError(t, err)
101+
updateSyncWindsurfStateDB(t, dbPath, windsurfSyncPayload("hash-session", "Bravo reply"))
102+
initialTime := time.Unix(0, initialMtime)
103+
require.NoError(t, os.Chtimes(dbPath, initialTime, initialTime))
104+
infoAfter, err := os.Stat(dbPath)
105+
require.NoError(t, err)
106+
require.Equal(t, infoBefore.Size(), infoAfter.Size(),
107+
"test must keep size stable so hash is the only freshness signal")
108+
109+
if tt.seedCache {
110+
engine.cacheSkip(first.cacheKey, initialMtime)
111+
}
112+
if tt.freshSync {
113+
engine.Close()
114+
engine = NewEngine(database, EngineConfig{
115+
AgentDirs: map[parser.AgentType][]string{
116+
parser.AgentWindsurf: {root},
117+
},
118+
Machine: "devbox",
119+
})
120+
defer engine.Close()
121+
}
122+
123+
second := engine.processFile(context.Background(), parser.DiscoveredFile{
124+
Path: virtualPath,
125+
Agent: parser.AgentWindsurf,
126+
})
127+
require.NoError(t, second.err)
128+
assert.False(t, second.skip)
129+
require.Len(t, second.results, 1)
130+
require.Len(t, second.results[0].Messages, 2)
131+
assert.Equal(t, "Bravo reply", second.results[0].Messages[1].Content)
132+
assert.NotEqual(t, initialHash, second.results[0].Session.File.Hash)
133+
})
134+
}
135+
}
136+
58137
func writeSyncWindsurfStateDB(t *testing.T, dbPath, payload string) {
59138
t.Helper()
60139
conn, err := sql.Open("sqlite3", dbPath)
@@ -69,3 +148,46 @@ func writeSyncWindsurfStateDB(t *testing.T, dbPath, payload string) {
69148
)
70149
require.NoError(t, err)
71150
}
151+
152+
func updateSyncWindsurfStateDB(t *testing.T, dbPath, payload string) {
153+
t.Helper()
154+
conn, err := sql.Open("sqlite3", dbPath)
155+
require.NoError(t, err)
156+
defer conn.Close()
157+
_, err = conn.Exec(
158+
`UPDATE ItemTable SET value = ? WHERE key = ?`,
159+
payload,
160+
"workbench.panel.aichat.view.aichat.chatdata",
161+
)
162+
require.NoError(t, err)
163+
}
164+
165+
func writeSyncWindsurfResult(t *testing.T, engine *Engine, result processResult) {
166+
t.Helper()
167+
require.Len(t, result.results, 1)
168+
written, _, failed := engine.writeBatch(
169+
[]pendingWrite{{
170+
sess: result.results[0].Session,
171+
msgs: result.results[0].Messages,
172+
usageEvents: result.results[0].UsageEvents,
173+
forceReplace: result.forceReplace,
174+
}},
175+
syncWriteDefault,
176+
false,
177+
)
178+
require.Equal(t, 0, failed)
179+
require.Equal(t, 1, written)
180+
}
181+
182+
func windsurfSyncPayload(sessionID, assistant string) string {
183+
return `{
184+
"version": 1,
185+
"sessionId": "` + sessionID + `",
186+
"requests": [{
187+
"requestId": "request-1",
188+
"message": {"text": "Question"},
189+
"response": [{"value": "` + assistant + `"}],
190+
"timestamp": 1710000000000
191+
}]
192+
}`
193+
}

0 commit comments

Comments
 (0)