| 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 |