返回 DeepSeek-Reasonix
session_catalog_rebuild_lifecycle_test.go
根目录 / desktop / session_catalog_rebuild_lifecycle_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/sessioncatalog"
15 )
16
17 func waitForSessionCatalogForTest(t *testing.T, app *App, previous *sessioncatalog.Catalog) *sessioncatalog.Catalog {
18 t.Helper()
19 deadline := time.Now().Add(3 * time.Second)
20 for time.Now().Before(deadline) {
21 catalog := app.sessionCatalog.Load()
22 if catalog != nil && catalog != previous {
23 return catalog
24 }
25 time.Sleep(5 * time.Millisecond)
26 }
27 t.Fatal("session catalog was not published before the deadline")
28 return nil
29 }
30
31 func assertSessionCatalogWatcherRunning(t *testing.T, app *App) {
32 t.Helper()
33 app.catalogLifecycleMu.Lock()
34 cancel := app.catalogCancel
35 done := app.catalogDone
36 app.catalogLifecycleMu.Unlock()
37 if cancel == nil || done == nil {
38 t.Fatal("session catalog watcher is not armed")
39 }
40 select {
41 case <-done:
42 t.Fatal("session catalog watcher exited instead of entering the refresh loop")
43 default:
44 }
45 }
46
47 func TestSessionCatalogRebuildingStatusPreservesPublishedCounts(t *testing.T) {
48 isolateDesktopUserDirs(t)
49 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
50 if err != nil {
51 t.Fatal(err)
52 }
53 dir := t.TempDir()
54 path := filepath.Join(dir, "counted.jsonl")
55 if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil {
56 t.Fatal(err)
57 }
58 if err := catalog.UpsertSession(context.Background(), sessioncatalog.SessionRecord{
59 Path: path, Directory: dir, Scope: "global", TopicID: "counted",
60 Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
61 }); err != nil {
62 t.Fatal(err)
63 }
64 app := NewApp()
65 app.sessionCatalog.Store(catalog)
66 published := sessionCatalogStatus(catalog.Status())
67 app.catalogRebuilding.Store(true)
68 t.Cleanup(func() {
69 app.catalogRebuilding.Store(false)
70 app.sessionCatalog.CompareAndSwap(catalog, nil)
71 _ = catalog.Close(context.Background())
72 })
73
74 status := app.currentSessionCatalogStatus()
75 if status.State != string(sessioncatalog.StateRebuilding) {
76 t.Fatalf("state = %q, want rebuilding", status.State)
77 }
78 if status.Indexed != published.Indexed || status.Total != published.Total || status.Revision != published.Revision {
79 t.Fatalf("rebuilding snapshot = %d/%d@%d, want published %d/%d@%d",
80 status.Indexed, status.Total, status.Revision, published.Indexed, published.Total, published.Revision)
81 }
82 if status.CanRebuild {
83 t.Fatal("an active rebuild must fail closed for another rebuild request")
84 }
85
86 app.sessionCatalog.Store(nil)
87 status = app.currentSessionCatalogStatus()
88 if status.State != string(sessioncatalog.StateRebuilding) || status.Total != 0 || status.CanRebuild {
89 t.Fatalf("catalog-close rebuild status = %+v, want unknown progress and no rebuild action", status)
90 }
91 }
92
93 func TestSessionCatalogStatusCanRebuildJSONContract(t *testing.T) {
94 ready := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateReady, Mode: sessioncatalog.ModeDisk})
95 degraded := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateDegraded, Mode: sessioncatalog.ModeMemory})
96 failed := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateReady, LastError: "catalog unavailable"})
97 opening := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateOpening, LastError: "still opening"})
98 closed := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateClosed, LastError: "closed"})
99 repairing := sessionCatalogStatus(sessioncatalog.Status{State: sessioncatalog.StateDegraded, RepairPending: 1, RepairActive: 1})
100 if ready.CanRebuild || !degraded.CanRebuild || !failed.CanRebuild || opening.CanRebuild || closed.CanRebuild || repairing.CanRebuild {
101 t.Fatalf("canRebuild policy ready/degraded/failed/opening/closed/repairing = %v/%v/%v/%v/%v/%v",
102 ready.CanRebuild, degraded.CanRebuild, failed.CanRebuild, opening.CanRebuild, closed.CanRebuild, repairing.CanRebuild)
103 }
104 for _, status := range []SessionCatalogStatus{ready, degraded, failed, opening, closed, repairing} {
105 body, err := json.Marshal(status)
106 if err != nil {
107 t.Fatal(err)
108 }
109 if !strings.Contains(string(body), `"canRebuild":`) {
110 t.Fatalf("Wails status omitted canRebuild: %s", body)
111 }
112 }
113 }
114
115 func TestRebuildSessionCatalogReturnsWithOrdinaryWatcherRunning(t *testing.T) {
116 isolateDesktopUserDirs(t)
117 app := NewApp()
118 app.startSessionCatalog()
119 oldCatalog := waitForSessionCatalogForTest(t, app, nil)
120 t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
121 for range 32 {
122 if err := oldCatalog.SyncMetadata(context.Background(), nil, nil); err != nil {
123 t.Fatal(err)
124 }
125 }
126 oldRevision := oldCatalog.Status().Revision
127 app.ctx = context.Background()
128 events := make(chan ProjectTreeChangedV2, 8)
129 app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
130 if name != "project-tree:changed-v2" || len(payload) != 1 {
131 return
132 }
133 if event, ok := payload[0].(ProjectTreeChangedV2); ok {
134 events <- event
135 }
136 }
137
138 if err := app.RebuildSessionCatalog(); err != nil {
139 t.Fatal(err)
140 }
141 if app.catalogRebuilding.Load() {
142 t.Fatal("RebuildSessionCatalog returned while rebuilding was still true")
143 }
144 assertSessionCatalogWatcherRunning(t, app)
145 newCatalog := waitForSessionCatalogForTest(t, app, oldCatalog)
146 if status := newCatalog.Status(); status.State != sessioncatalog.StateReady {
147 t.Fatalf("replacement watcher status = %q, want ready", status.State)
148 }
149 if revision := newCatalog.Status().Revision; revision < oldRevision {
150 t.Fatalf("replacement watcher revision = %d, want at least previous revision %d", revision, oldRevision)
151 }
152 deadline := time.After(3 * time.Second)
153 for {
154 select {
155 case event := <-events:
156 if event.Reason != "catalog_rebuild_finished" {
157 continue
158 }
159 if event.Revision < oldRevision {
160 t.Fatalf("finished event revision = %d, want at least previous revision %d", event.Revision, oldRevision)
161 }
162 goto finishedEventObserved
163 case <-deadline:
164 t.Fatal("catalog rebuild finished event was not published")
165 }
166 }
167
168 finishedEventObserved:
169 if app.catalogRebuilding.Load() {
170 t.Fatal("the long-lived watcher took ownership of rebuilding")
171 }
172 }
173
174 func TestRebuildSessionCatalogFailureKeepsProjectionAndRestartsWatcher(t *testing.T) {
175 isolateDesktopUserDirs(t)
176 dir := config.SessionDir()
177 if err := os.MkdirAll(dir, 0o755); err != nil {
178 t.Fatal(err)
179 }
180 path := filepath.Join(dir, "keep.jsonl")
181 body := []byte(`{"role":"user","content":"keep me"}` + "\n")
182 if err := os.WriteFile(path, body, 0o600); err != nil {
183 t.Fatal(err)
184 }
185 if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
186 ID: agent.BranchID(path), Scope: "global", TopicID: "keep", TopicTitle: "Keep",
187 }); err != nil {
188 t.Fatal(err)
189 }
190
191 app := NewApp()
192 app.startSessionCatalog()
193 oldCatalog := waitForSessionCatalogForTest(t, app, nil)
194 t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
195 if err := oldCatalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
196 t.Fatal(err)
197 }
198 if _, ok, err := oldCatalog.GetTopic(context.Background(), sessioncatalog.TopicKey{Scope: "global", TopicID: "keep"}); err != nil || !ok {
199 t.Fatalf("seed topic missing before failed rebuild: ok=%v err=%v", ok, err)
200 }
201 metaPath := agent.BranchMetaPath(path)
202 var metaBody []byte
203 var err error
204 deadline := time.Now().Add(3 * time.Second)
205 for time.Now().Before(deadline) {
206 metaBody, err = os.ReadFile(metaPath)
207 if err == nil && strings.Contains(string(metaBody), `"schema_version": 2`) {
208 break
209 }
210 time.Sleep(5 * time.Millisecond)
211 }
212 if !strings.Contains(string(metaBody), `"schema_version": 2`) {
213 t.Fatalf("listing sidecar did not settle before rebuild: %v: %s", err, metaBody)
214 }
215
216 projectRoot := filepath.Join(t.TempDir(), "broken-project")
217 if err := addProject(projectRoot, "Broken project"); err != nil {
218 t.Fatal(err)
219 }
220 badTarget := desktopSessionDir(projectRoot)
221 if err := os.MkdirAll(filepath.Dir(badTarget), 0o755); err != nil {
222 t.Fatal(err)
223 }
224 if err := os.WriteFile(badTarget, []byte("not a directory"), 0o600); err != nil {
225 t.Fatal(err)
226 }
227
228 if err := app.RebuildSessionCatalog(); err == nil {
229 t.Fatal("expected rebuild to fail for a session target that is a regular file")
230 }
231 if app.catalogRebuilding.Load() {
232 t.Fatal("failed rebuild left rebuilding set")
233 }
234 assertSessionCatalogWatcherRunning(t, app)
235 newCatalog := waitForSessionCatalogForTest(t, app, oldCatalog)
236 if _, ok, err := newCatalog.GetTopic(context.Background(), sessioncatalog.TopicKey{Scope: "global", TopicID: "keep"}); err != nil || !ok {
237 t.Fatalf("failed rebuild lost the existing projection: ok=%v err=%v", ok, err)
238 }
239 after, err := os.ReadFile(path)
240 if err != nil {
241 t.Fatal(err)
242 }
243 if string(after) != string(body) {
244 t.Fatalf("failed rebuild modified the authoritative transcript: got %q want %q", after, body)
245 }
246 metaAfter, err := os.ReadFile(metaPath)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if string(metaAfter) != string(metaBody) {
251 t.Fatalf("failed rebuild modified the authoritative sidecar: got %q want %q", metaAfter, metaBody)
252 }
253 }
254
255 func TestRebuildSessionCatalogDoesNotRestartDuringShutdown(t *testing.T) {
256 app := NewApp()
257 app.shuttingDown.Store(true)
258 if err := app.RebuildSessionCatalog(); err == nil {
259 t.Fatal("rebuild during shutdown must fail")
260 }
261 app.catalogLifecycleMu.Lock()
262 cancel, done := app.catalogCancel, app.catalogDone
263 app.catalogLifecycleMu.Unlock()
264 if cancel != nil || done != nil || app.catalogRebuilding.Load() {
265 t.Fatal("shutdown rebuild armed catalog lifecycle state")
266 }
267 }
268
268 lines GO