返回 DeepSeek-Reasonix
path_identity_test.go
根目录 / internal / sessioncatalog / path_identity_test.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11
12 "reasonix/internal/agent"
13 )
14
15 func TestPathIdentityKeyMatchesFilesystemCaseSemantics(t *testing.T) {
16 dir := t.TempDir()
17 original := filepath.Join(dir, "MixedCase.jsonl")
18 variant := filepath.Join(dir, "mixedcase.jsonl")
19 if err := os.WriteFile(original, []byte("session"), 0o600); err != nil {
20 t.Fatal(err)
21 }
22
23 originalInfo, err := os.Stat(original)
24 if err != nil {
25 t.Fatal(err)
26 }
27 variantInfo, variantErr := os.Stat(variant)
28 if variantErr == nil && os.SameFile(originalInfo, variantInfo) {
29 if got, want := PathIdentityKey(variant), PathIdentityKey(original); got != want {
30 t.Fatalf("case-insensitive filesystem keys differ: %q != %q", got, want)
31 }
32 return
33 }
34 if variantErr != nil && !os.IsNotExist(variantErr) {
35 t.Fatal(variantErr)
36 }
37 if err := os.WriteFile(variant, []byte("other session"), 0o600); err != nil {
38 t.Skipf("filesystem cannot create case-distinct files: %v", err)
39 }
40 variantInfo, err = os.Stat(variant)
41 if err != nil {
42 t.Fatal(err)
43 }
44 if os.SameFile(originalInfo, variantInfo) {
45 t.Skip("filesystem aliases the two case spellings")
46 }
47 if got, other := PathIdentityKey(original), PathIdentityKey(variant); got == other {
48 t.Fatalf("case-sensitive filesystem collapsed distinct files to %q", got)
49 }
50 }
51
52 func TestUniqueDirectoryTargetsUsesIdentityAndPreservesAccessSpelling(t *testing.T) {
53 targets := []DirectoryTarget{
54 {Path: filepath.Join("sessions", "Foo"), Scope: "project", WorkspaceRoot: "/work/first"},
55 {Path: filepath.Join("sessions", "foo"), Scope: "project", WorkspaceRoot: "/work/second"},
56 {Path: " "},
57 }
58
59 folded := uniqueDirectoryTargetsBy(targets, func(path string) string {
60 return strings.ToLower(filepath.Clean(path))
61 })
62 if len(folded) != 1 {
63 t.Fatalf("folded targets = %#v, want one", folded)
64 }
65 if folded[0].Path != filepath.Join("sessions", "Foo") || folded[0].WorkspaceRoot != "/work/first" {
66 t.Fatalf("first access spelling was not preserved: %#v", folded[0])
67 }
68
69 exact := uniqueDirectoryTargetsBy(targets, filepath.Clean)
70 if len(exact) != 2 {
71 t.Fatalf("case-sensitive targets = %#v, want two", exact)
72 }
73 }
74
75 func TestReconcileDirectoryUsesFilesystemIdentityWhenCaseInsensitive(t *testing.T) {
76 ctx := context.Background()
77 root := t.TempDir()
78 originalDir := filepath.Join(root, "MixedDirectory")
79 variantDir := filepath.Join(root, "mixeddirectory")
80 if err := os.Mkdir(originalDir, 0o700); err != nil {
81 t.Fatal(err)
82 }
83 originalInfo, err := os.Stat(originalDir)
84 if err != nil {
85 t.Fatal(err)
86 }
87 variantInfo, err := os.Stat(variantDir)
88 if err != nil || !os.SameFile(originalInfo, variantInfo) {
89 t.Skip("test requires a case-insensitive filesystem directory")
90 }
91 sessionPath := filepath.Join(originalDir, "Session.jsonl")
92 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil {
93 t.Fatal(err)
94 }
95 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
96 Scope: "global", TopicID: "topic", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
97 }); err != nil {
98 t.Fatal(err)
99 }
100
101 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
102 if err != nil {
103 t.Fatal(err)
104 }
105 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
106 for _, dir := range []string{originalDir, variantDir} {
107 if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
108 t.Fatal(err)
109 }
110 }
111 page, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Limit: 10})
112 if err != nil {
113 t.Fatal(err)
114 }
115 if len(page.Items) != 1 {
116 t.Fatalf("case-variant directory scans produced %#v, want one session", page.Items)
117 }
118 var directories int
119 if err := catalog.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_directories`).Scan(&directories); err != nil {
120 t.Fatal(err)
121 }
122 if directories != 1 {
123 t.Fatalf("case-variant directory scans produced %d directory rows, want one", directories)
124 }
125 }
126
127 func TestCatalogPathIdentityDefersFilesystemResolutionFromEnqueue(t *testing.T) {
128 identityCalls := 0
129 fold := func(path string) string {
130 identityCalls++
131 return strings.ToLower(filepath.Clean(path))
132 }
133 catalog := &Catalog{
134 pathIdentity: fold,
135 writeCh: make(chan string, 2),
136 writeQueued: map[string]SessionRecord{},
137 repairCh: make(chan string, 2),
138 reconcileCh: make(chan DirectoryTarget, 2),
139 reconcileDirty: map[string]DirectoryTarget{},
140 pathCh: make(chan sessionPathRequest, 2),
141 directoryLocks: map[string]*sync.Mutex{},
142 stop: make(chan struct{}),
143 }
144 upperPath := filepath.Join(string(filepath.Separator), "Sessions", "Mixed.jsonl")
145 lowerPath := filepath.Join(string(filepath.Separator), "sessions", "mixed.jsonl")
146 upperDir, lowerDir := filepath.Dir(upperPath), filepath.Dir(lowerPath)
147
148 first := SessionRecord{Path: upperPath, Directory: upperDir}
149 second := SessionRecord{Path: lowerPath, Directory: lowerDir}
150 if !catalog.EnqueueSession(first) || !catalog.EnqueueSession(second) {
151 t.Fatal("write queue rejected a case variant")
152 }
153 if len(catalog.writeQueued) != 2 || len(catalog.writeCh) != 2 {
154 t.Fatalf("lexical write staging = rows=%d signals=%d, want both events", len(catalog.writeQueued), len(catalog.writeCh))
155 }
156 if !catalog.RequestReconcile(DirectoryTarget{Path: upperDir}) || !catalog.RequestReconcile(DirectoryTarget{Path: lowerDir}) {
157 t.Fatal("reconcile queue rejected a case variant")
158 }
159 queuedReconciles := 0
160 catalog.reconcileQueued.Range(func(_, _ any) bool { queuedReconciles++; return true })
161 if queuedReconciles != 2 || len(catalog.reconcileCh) != 2 || len(catalog.reconcileDirty) != 0 {
162 t.Fatalf("lexical reconcile staging: queued=%d signals=%d dirty=%d", queuedReconciles, len(catalog.reconcileCh), len(catalog.reconcileDirty))
163 }
164 if !catalog.RequestIndexSession(DirectoryTarget{Path: upperDir}, upperPath) ||
165 !catalog.RequestIndexSession(DirectoryTarget{Path: lowerDir}, lowerPath) {
166 t.Fatal("direct-index queue rejected a case variant")
167 }
168 queuedPaths := 0
169 catalog.pathQueued.Range(func(_, _ any) bool { queuedPaths++; return true })
170 if queuedPaths != 2 || len(catalog.pathCh) != 2 {
171 t.Fatalf("lexical direct-index staging: queued=%d signals=%d", queuedPaths, len(catalog.pathCh))
172 }
173 if identityCalls != 0 {
174 t.Fatalf("enqueue APIs performed %d filesystem identity resolutions", identityCalls)
175 }
176 catalog.enqueueRepair(upperPath)
177 catalog.enqueueRepair(lowerPath)
178 queuedRepairs := 0
179 catalog.repairQueued.Range(func(_, _ any) bool { queuedRepairs++; return true })
180 if queuedRepairs != 1 || len(catalog.repairCh) != 1 {
181 t.Fatalf("repair queue split identity: queued=%d signals=%d", queuedRepairs, len(catalog.repairCh))
182 }
183 if catalog.directoryLock(upperDir) != catalog.directoryLock(lowerDir) {
184 t.Fatal("case variants acquired different directory locks")
185 }
186 }
187
188 func TestCatalogPathIdentityDeduplicatesEveryMutationBoundary(t *testing.T) {
189 ctx := context.Background()
190 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
191 if err != nil {
192 t.Fatal(err)
193 }
194 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
195 catalog.pathIdentity = func(path string) string {
196 return strings.ToLower(filepath.Clean(path))
197 }
198
199 upperPath := filepath.Join(string(filepath.Separator), "Sessions", "Mixed.jsonl")
200 lowerPath := filepath.Join(string(filepath.Separator), "sessions", "mixed.jsonl")
201 upperDir := filepath.Dir(upperPath)
202 lowerDir := filepath.Dir(lowerPath)
203 first := SessionRecord{
204 Path: upperPath, Directory: upperDir, Scope: "global", TopicID: "topic",
205 Preview: "first", LastActivityAt: 1, TurnsState: TurnsValid, Health: HealthOK,
206 }
207 second := first
208 second.Path = lowerPath
209 second.Directory = lowerDir
210 second.Preview = "second"
211 second.LastActivityAt = 2
212 if err := catalog.UpsertSession(ctx, first); err != nil {
213 t.Fatal(err)
214 }
215 if err := catalog.UpsertSession(ctx, second); err != nil {
216 t.Fatal(err)
217 }
218
219 page, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Limit: 10})
220 if err != nil {
221 t.Fatal(err)
222 }
223 if len(page.Items) != 1 || page.Items[0].Path != lowerPath || page.Items[0].Preview != "second" {
224 t.Fatalf("deduplicated sessions = %#v, want latest access spelling", page.Items)
225 }
226 if got, ok, err := catalog.GetSession(ctx, upperPath); err != nil || !ok || got.Path != lowerPath {
227 t.Fatalf("GetSession(case variant) = %#v, %v, %v", got, ok, err)
228 }
229 if got, err := catalog.CountDirectorySessions(ctx, upperDir); err != nil || got != 1 {
230 t.Fatalf("CountDirectorySessions(case variant) = %d, %v", got, err)
231 }
232
233 if err := catalog.RemoveSession(ctx, upperPath, "test"); err != nil {
234 t.Fatal(err)
235 }
236 if _, ok, err := catalog.GetSession(ctx, lowerPath); err != nil || ok {
237 t.Fatalf("removed case variant remained visible: ok=%v err=%v", ok, err)
238 }
239 }
240
241 func TestCatalogPathIdentityPreservesCaseDistinctSessions(t *testing.T) {
242 ctx := context.Background()
243 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
244 if err != nil {
245 t.Fatal(err)
246 }
247 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
248 catalog.pathIdentity = filepath.Clean
249
250 for index, path := range []string{"/sessions/Foo.jsonl", "/sessions/foo.jsonl"} {
251 if err := catalog.UpsertSession(ctx, SessionRecord{
252 Path: path, Directory: filepath.Dir(path), Scope: "global", TopicID: "topic",
253 LastActivityAt: int64(index + 1), TurnsState: TurnsValid, Health: HealthOK,
254 }); err != nil {
255 t.Fatal(err)
256 }
257 }
258 page, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Limit: 10})
259 if err != nil {
260 t.Fatal(err)
261 }
262 if len(page.Items) != 2 {
263 t.Fatalf("case-distinct sessions = %#v, want two", page.Items)
264 }
265 }
266
267 func TestWorkspaceRootIdentityJoinsRegistryAndSidecarSpellings(t *testing.T) {
268 ctx := context.Background()
269 revisionRoots := [][]string{}
270 catalog, err := Open(ctx, Options{
271 Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true,
272 OnRevision: func(_ uint64, roots []string, _ string) {
273 revisionRoots = append(revisionRoots, append([]string(nil), roots...))
274 },
275 })
276 if err != nil {
277 t.Fatal(err)
278 }
279 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
280 identityCalls := 0
281 catalog.pathIdentity = func(path string) string {
282 identityCalls++
283 return strings.ToLower(filepath.Clean(path))
284 }
285
286 upperRoot := filepath.Join(string(filepath.Separator), "Workspaces", "Project")
287 lowerRoot := filepath.Join(string(filepath.Separator), "workspaces", "project")
288 if err := catalog.UpsertSession(ctx, SessionRecord{
289 Path: filepath.Join(lowerRoot, "sessions", "one.jsonl"), Directory: filepath.Join(lowerRoot, "sessions"),
290 Scope: "project", WorkspaceRoot: lowerRoot, TopicID: "topic", Preview: "from sidecar",
291 LastActivityAt: 1, TurnsState: TurnsValid, Health: HealthOK,
292 }); err != nil {
293 t.Fatal(err)
294 }
295 if err := catalog.SyncMetadata(ctx,
296 []ProjectRecord{{Scope: "project", WorkspaceRoot: upperRoot, Title: "Project"}},
297 []TopicMetadata{{Scope: "project", WorkspaceRoot: upperRoot, TopicID: "topic", Title: "Registry title"}},
298 ); err != nil {
299 t.Fatal(err)
300 }
301 revisionRoots = nil
302 if err := catalog.UpsertSession(ctx, SessionRecord{
303 Path: filepath.Join(lowerRoot, "sessions", "one.jsonl"), Directory: filepath.Join(lowerRoot, "sessions"),
304 Scope: "project", WorkspaceRoot: lowerRoot, TopicID: "topic", Preview: "updated sidecar",
305 LastActivityAt: 2, TurnsState: TurnsValid, Health: HealthOK,
306 }); err != nil {
307 t.Fatal(err)
308 }
309 if len(revisionRoots) != 1 || len(revisionRoots[0]) != 1 || revisionRoots[0][0] != upperRoot {
310 t.Fatalf("sidecar revision roots = %#v, want registry spelling %q", revisionRoots, upperRoot)
311 }
312
313 identityCalls = 0
314 topics, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "project", WorkspaceRoot: upperRoot, Limit: 10})
315 if err != nil {
316 t.Fatal(err)
317 }
318 if len(topics.Items) != 1 || len(topics.Items[0].Sessions) != 1 {
319 t.Fatalf("registry spelling topics = %#v, want joined sidecar session", topics.Items)
320 }
321 if identityCalls != 1 {
322 t.Fatalf("ListTopics workspace identity calls = %d, want one per request", identityCalls)
323 }
324 sessions, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "project", WorkspaceRoot: upperRoot, Limit: 10})
325 if err != nil {
326 t.Fatal(err)
327 }
328 if len(sessions.Items) != 1 || sessions.Items[0].WorkspaceRoot != lowerRoot {
329 t.Fatalf("registry spelling sessions = %#v, want original sidecar access spelling", sessions.Items)
330 }
331 }
332
333 func TestCatalogIdentityRemapReplacesSameAccessSpelling(t *testing.T) {
334 ctx := context.Background()
335 dir := t.TempDir()
336 path := filepath.Join(dir, "session.jsonl")
337 root := filepath.Join(dir, "workspace-link")
338 if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
339 t.Fatal(err)
340 }
341 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
342 Scope: "project", WorkspaceRoot: root, TopicID: "topic", Preview: "first identity",
343 SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
344 }); err != nil {
345 t.Fatal(err)
346 }
347 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
348 if err != nil {
349 t.Fatal(err)
350 }
351 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
352 generation := "identity-a:"
353 catalog.pathIdentity = func(value string) string {
354 return generation + filepath.Clean(value)
355 }
356 target := DirectoryTarget{Path: dir, Scope: "project", WorkspaceRoot: root}
357 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
358 t.Fatal(err)
359 }
360
361 generation = "identity-b:"
362 if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error {
363 meta.Preview = "second identity after remap"
364 meta.Turns = 2
365 return nil
366 }); err != nil {
367 t.Fatal(err)
368 }
369 if err := catalog.ReconcileDirectory(ctx, target); err != nil {
370 t.Fatal(err)
371 }
372
373 for _, table := range []string{"catalog_directories", "catalog_sessions", "catalog_topics"} {
374 var count int
375 if err := catalog.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&count); err != nil {
376 t.Fatal(err)
377 }
378 if count != 1 {
379 t.Fatalf("%s rows after identity remap = %d, want one", table, count)
380 }
381 }
382 got, ok, err := catalog.GetSession(ctx, path)
383 if err != nil || !ok || got.Preview != "second identity after remap" || got.Turns != 2 {
384 t.Fatalf("remapped session = %#v, %v, %v", got, ok, err)
385 }
386 page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
387 if err != nil || len(page.Items) != 1 || len(page.Items[0].Sessions) != 1 {
388 t.Fatalf("remapped topics = %#v, %v", page.Items, err)
389 }
390 }
391
392 func TestWorkspaceRootIdentityPreservesCaseDistinctProjects(t *testing.T) {
393 ctx := context.Background()
394 catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
395 if err != nil {
396 t.Fatal(err)
397 }
398 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
399 catalog.pathIdentity = filepath.Clean
400
401 roots := []string{"/Workspaces/Foo", "/Workspaces/foo"}
402 for index, root := range roots {
403 if err := catalog.UpsertSession(ctx, SessionRecord{
404 Path: filepath.Join(root, "sessions", fmt.Sprintf("%d.jsonl", index)), Directory: filepath.Join(root, "sessions"),
405 Scope: "project", WorkspaceRoot: root, TopicID: "topic", LastActivityAt: int64(index + 1),
406 TurnsState: TurnsValid, Health: HealthOK,
407 }); err != nil {
408 t.Fatal(err)
409 }
410 }
411 for _, root := range roots {
412 page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
413 if err != nil {
414 t.Fatal(err)
415 }
416 if len(page.Items) != 1 || len(page.Items[0].Sessions) != 1 || page.Items[0].Sessions[0].WorkspaceRoot != root {
417 t.Fatalf("case-distinct workspace %q topics = %#v", root, page.Items)
418 }
419 }
420 }
421
421 lines GO