| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/desktop/internal/workspacestate" |
| 16 | "reasonix/internal/agent" |
| 17 | "reasonix/internal/config" |
| 18 | "reasonix/internal/provider" |
| 19 | "reasonix/internal/session" |
| 20 | "reasonix/internal/store" |
| 21 | ) |
| 22 | |
| 23 | func writeMigrationJSON(t *testing.T, path string, value any) { |
| 24 | t.Helper() |
| 25 | body, err := json.Marshal(value) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func migrationSourceSnapshot(t *testing.T, paths []string) map[string][]byte { |
| 38 | t.Helper() |
| 39 | result := map[string][]byte{} |
| 40 | for _, path := range paths { |
| 41 | body, err := os.ReadFile(path) |
| 42 | if os.IsNotExist(err) { |
| 43 | continue |
| 44 | } |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | result[path] = body |
| 49 | } |
| 50 | return result |
| 51 | } |
| 52 | |
| 53 | func assertMigrationSourceSnapshot(t *testing.T, before map[string][]byte) { |
| 54 | t.Helper() |
| 55 | for path, body := range before { |
| 56 | after, err := os.ReadFile(path) |
| 57 | if err != nil || !bytes.Equal(body, after) { |
| 58 | t.Fatalf("source changed: %s: %v", path, err) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func v5MigrationHistories(t *testing.T, app *App) map[string][]string { |
| 64 | t.Helper() |
| 65 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | result := map[string][]string{} |
| 70 | for _, workspace := range state.Workspaces { |
| 71 | for _, id := range workspace.SessionIDs { |
| 72 | page, err := app.ReadSessionHistory(session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "", 100) |
| 73 | if err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | var content []string |
| 77 | for _, message := range page.Messages { |
| 78 | content = append(content, message.Content) |
| 79 | } |
| 80 | result[id] = content |
| 81 | } |
| 82 | } |
| 83 | return result |
| 84 | } |
| 85 | |
| 86 | // Make a real, validated commit with the public writer, then encode it using |
| 87 | // the historical wire envelope. The retired format uses JSONL, not frames. |
| 88 | func writeV3MigrationFixture(t *testing.T, root, id, codec string, messages []provider.Message) { |
| 89 | t.Helper() |
| 90 | tmp := filepath.Join(t.TempDir(), id) |
| 91 | writer, err := session.CreateStore(tmp, id) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | var events []session.Event |
| 96 | for _, message := range messages { |
| 97 | payload, _ := json.Marshal(map[string]any{"message": message}) |
| 98 | events = append(events, session.Event{Kind: "message/complete", Payload: payload}) |
| 99 | } |
| 100 | if _, err := writer.Append(t.Context(), session.Batch{OperationID: "fixture", Events: events}); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if err := writer.Close(t.Context()); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | commits, err := session.Replay(tmp, nil) |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | dir := filepath.Join(root, id) |
| 111 | writeMigrationJSON(t, filepath.Join(dir, "manifest.json"), session.Manifest{SchemaVersion: 3, Codec: codec, SessionID: id, WriterGeneration: 1, CreatedAt: time.Now().UTC()}) |
| 112 | var log bytes.Buffer |
| 113 | for _, commit := range commits { |
| 114 | commit.SchemaVersion, commit.Codec = 3, codec |
| 115 | if err := json.NewEncoder(&log).Encode(commit); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | } |
| 119 | if err := os.WriteFile(filepath.Join(dir, "events.jsonl"), log.Bytes(), 0o600); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestDesktopV5UpgradeV1WithoutMetadata(t *testing.T) { |
| 125 | for _, scope := range []string{"global", "project"} { |
| 126 | for _, events := range []bool{false, true} { |
| 127 | t.Run(scope+"/events="+map[bool]string{false: "no", true: "yes"}[events], func(t *testing.T) { |
| 128 | isolateDesktopUserDirs(t) |
| 129 | root := config.SessionDir() |
| 130 | if scope == "project" { |
| 131 | workspace := filepath.Join(t.TempDir(), "中文旧项目") |
| 132 | root = desktopSessionDir(workspace) |
| 133 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | } |
| 137 | path := filepath.Join(root, "old.jsonl") |
| 138 | writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "checkpoint"}) |
| 139 | want := "checkpoint" |
| 140 | if events { |
| 141 | want = "newer-v1-event" |
| 142 | writeMigrationJSON(t, store.SessionEventLog(path), struct { |
| 143 | SchemaVersion int `json:"schema_version"` |
| 144 | Type string `json:"type"` |
| 145 | Messages []provider.Message `json:"messages"` |
| 146 | }{1, "replace", []provider.Message{{Role: provider.RoleUser, Content: want}}}) |
| 147 | } |
| 148 | // These are not independent transcripts even though they end in JSONL. |
| 149 | for _, suffix := range []string{".turns.jsonl", ".guardian.jsonl", ".conflicts.jsonl"} { |
| 150 | if err := os.WriteFile(filepath.Join(root, "old"+suffix), []byte("not a conversation\n"), 0o600); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | } |
| 154 | before := migrationSourceSnapshot(t, legacyMigrationSourceFiles(path)) |
| 155 | app := NewApp() |
| 156 | t.Cleanup(app.closeSessionServices) |
| 157 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | histories := v5MigrationHistories(t, app) |
| 161 | if len(histories) != 1 { |
| 162 | t.Fatalf("histories=%v", histories) |
| 163 | } |
| 164 | for id, history := range histories { |
| 165 | if !reflect.DeepEqual(history, []string{want}) { |
| 166 | t.Fatalf("history=%v", history) |
| 167 | } |
| 168 | appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "continued") |
| 169 | } |
| 170 | app.closeSessionServices() |
| 171 | app = NewApp() |
| 172 | t.Cleanup(app.closeSessionServices) |
| 173 | // A completed source must work without staging or history replay. |
| 174 | t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) |
| 175 | for range 2 { |
| 176 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | } |
| 180 | after := v5MigrationHistories(t, app) |
| 181 | if len(after) != 1 { |
| 182 | t.Fatalf("duplicate after continuation: %v", after) |
| 183 | } |
| 184 | for _, history := range after { |
| 185 | if history[len(history)-1] != "continued" { |
| 186 | t.Fatalf("lost continued history: %v", history) |
| 187 | } |
| 188 | } |
| 189 | assertMigrationSourceSnapshot(t, before) |
| 190 | }) |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestDesktopV5UpgradeAllV2HeadsAndOldReceipt(t *testing.T) { |
| 196 | for _, previousMigration := range []bool{false, true} { |
| 197 | t.Run(map[bool]string{false: "fresh", true: "previous-migrator"}[previousMigration], func(t *testing.T) { |
| 198 | isolateDesktopUserDirs(t) |
| 199 | path := filepath.Join(config.SessionDir(), "branches.jsonl") |
| 200 | legacy := agent.NewSession("system") |
| 201 | legacy.Add(provider.Message{ID: "question", Role: provider.RoleUser, Content: "root question"}) |
| 202 | legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "root answer"}) |
| 203 | if err := legacy.Save(path); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | forkAt := legacy.Snapshot()[2].ID |
| 207 | child, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "child") |
| 208 | if err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | legacy.Add(provider.Message{ID: "child-only", Role: provider.RoleUser, Content: "child only"}) |
| 212 | if err := legacy.Save(path); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | heads, err := agent.ListSessionHeads(path) |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | var rootHead string |
| 220 | for _, head := range heads { |
| 221 | if head.ID != child { |
| 222 | rootHead = head.ID |
| 223 | } |
| 224 | } |
| 225 | retired, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "deleted") |
| 226 | if err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | legacy.Add(provider.Message{ID: "deleted-work", Role: provider.RoleUser, Content: "retired branch"}) |
| 230 | if err := legacy.Save(path); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := legacy.SwitchHead(path, child); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if err := agent.RetireSessionHead(path, retired); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | app := NewApp() |
| 240 | t.Cleanup(app.closeSessionServices) |
| 241 | var oldTarget string |
| 242 | if previousMigration { |
| 243 | if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global"}, ""); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | oldTarget = onlyMigrationRecord(t).TargetSessionID |
| 247 | appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: oldTarget}, "continued-before-upgrade") |
| 248 | // Upgrade must recognize the receipt even if the old app selected |
| 249 | // another head after that migration. |
| 250 | if err := legacy.SwitchHead(path, rootHead); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | } |
| 254 | before := migrationSourceSnapshot(t, legacyMigrationSourceFiles(path)) |
| 255 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | histories := v5MigrationHistories(t, app) |
| 259 | if len(histories) != 2 { |
| 260 | t.Fatalf("expected both branches: %v", histories) |
| 261 | } |
| 262 | if previousMigration && histories[oldTarget][len(histories[oldTarget])-1] != "continued-before-upgrade" { |
| 263 | t.Fatalf("old adopted target lost: %v", histories) |
| 264 | } |
| 265 | for id := range histories { |
| 266 | appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "v5-continued") |
| 267 | } |
| 268 | app.closeSessionServices() |
| 269 | app = NewApp() |
| 270 | t.Cleanup(app.closeSessionServices) |
| 271 | tmp := t.TempDir() |
| 272 | t.Setenv("TMPDIR", filepath.Join(tmp, "missing")) |
| 273 | for range 2 { |
| 274 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | } |
| 278 | if got := v5MigrationHistories(t, app); len(got) != 2 { |
| 279 | t.Fatalf("duplicates: %v", got) |
| 280 | } |
| 281 | assertMigrationSourceSnapshot(t, before) |
| 282 | t.Setenv("TMPDIR", tmp) |
| 283 | if err := legacy.SwitchHead(path, child); err != nil { |
| 284 | t.Fatal(err) |
| 285 | } |
| 286 | third, err := legacy.ForkHead(path, forkAt, agent.HeadKindFork, "third") |
| 287 | if err != nil || third == "" { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | legacy.Add(provider.Message{ID: "third-work", Role: provider.RoleUser, Content: "third branch"}) |
| 291 | if err := legacy.Save(path); err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if got := v5MigrationHistories(t, app); len(got) != 3 { |
| 298 | t.Fatalf("new head missing or original duplicated: %v", got) |
| 299 | } |
| 300 | ledger, err := readDesktopMigrationLedger() |
| 301 | if err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | primary := ledger.Records[desktopLegacyMigrationKey(path)].LegacyPrimaryHead |
| 305 | if err := agent.RetireSessionHead(path, primary); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | if got := v5MigrationHistories(t, app); len(got) != 3 { |
| 312 | t.Fatalf("retiring primary changed other head identities: %v", got) |
| 313 | } |
| 314 | }) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | func TestDesktopV5UpgradeV3Formats(t *testing.T) { |
| 319 | for _, codec := range []string{session.PrototypeCodec, session.LegacyLinearCodec, session.FinalV31Codec} { |
| 320 | t.Run(codec, func(t *testing.T) { |
| 321 | isolateDesktopUserDirs(t) |
| 322 | root, id := filepath.Join(filepath.Dir(config.SessionStoreDir()), "sessions-v3"), "old-v3" |
| 323 | writeV3MigrationFixture(t, root, id, codec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "v3 history"}}) |
| 324 | before := migrationSourceSnapshot(t, canonicalMigrationSourceFiles(root, id)) |
| 325 | app := NewApp() |
| 326 | t.Cleanup(app.closeSessionServices) |
| 327 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | first := onlyMigrationRecord(t) |
| 331 | histories := v5MigrationHistories(t, app) |
| 332 | if !reflect.DeepEqual(histories[first.TargetSessionID], []string{"v3 history"}) { |
| 333 | t.Fatalf("history=%v", histories) |
| 334 | } |
| 335 | appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: first.TargetSessionID}, "v5 continued") |
| 336 | app.closeSessionServices() |
| 337 | app = NewApp() |
| 338 | t.Cleanup(app.closeSessionServices) |
| 339 | t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) |
| 340 | for range 2 { |
| 341 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | } |
| 345 | if record := onlyMigrationRecord(t); record.TargetSessionID != first.TargetSessionID || record.Attempts != first.Attempts { |
| 346 | t.Fatalf("remigrated: %#v", record) |
| 347 | } |
| 348 | assertMigrationSourceSnapshot(t, before) |
| 349 | }) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | func TestDesktopV5UpgradePairedV3AndLegacy(t *testing.T) { |
| 354 | for _, relation := range []string{"events-newer", "transcript-newer", "divergent"} { |
| 355 | t.Run(relation, func(t *testing.T) { |
| 356 | isolateDesktopUserDirs(t) |
| 357 | path := filepath.Join(config.SessionDir(), "paired.jsonl") |
| 358 | legacy := agent.NewSession("system") |
| 359 | legacy.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "common"}) |
| 360 | if err := legacy.Save(path); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | messages := legacy.Snapshot() |
| 364 | switch relation { |
| 365 | case "events-newer": |
| 366 | messages = append(messages, provider.Message{ID: "newer", Role: provider.RoleAssistant, Content: "event reply"}) |
| 367 | case "transcript-newer": |
| 368 | legacy.Add(provider.Message{ID: "newer", Role: provider.RoleAssistant, Content: "transcript reply"}) |
| 369 | if err := legacy.Save(path); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | case "divergent": |
| 373 | messages[len(messages)-1].Content = "conflict" |
| 374 | } |
| 375 | writeV3MigrationFixture(t, config.SessionStoreDir(), agent.BranchID(path), session.LegacyLinearCodec, messages) |
| 376 | before := migrationSourceSnapshot(t, desktopLegacySourceFiles(path, config.SessionStoreDir())) |
| 377 | app := NewApp() |
| 378 | t.Cleanup(app.closeSessionServices) |
| 379 | err := app.migrateDesktopSessionsV5(t.Context()) |
| 380 | if relation == "divergent" { |
| 381 | if err == nil { |
| 382 | t.Fatal("divergent history silently selected") |
| 383 | } |
| 384 | if got := v5MigrationHistories(t, app); len(got) != 0 { |
| 385 | t.Fatalf("published conflicting source: %v", got) |
| 386 | } |
| 387 | } else { |
| 388 | if err != nil { |
| 389 | t.Fatal(err) |
| 390 | } |
| 391 | histories := v5MigrationHistories(t, app) |
| 392 | if len(histories) != 1 { |
| 393 | t.Fatalf("paired history duplicated: %v", histories) |
| 394 | } |
| 395 | for _, history := range histories { |
| 396 | if !strings.HasSuffix(history[len(history)-1], "reply") { |
| 397 | t.Fatalf("new work lost: %v", history) |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | assertMigrationSourceSnapshot(t, before) |
| 402 | }) |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | func TestDesktopV5UpgradeLegacyFailureRetryAndSibling(t *testing.T) { |
| 407 | isolateDesktopUserDirs(t) |
| 408 | root := config.SessionDir() |
| 409 | writeMigrationJSON(t, filepath.Join(root, "healthy.jsonl"), provider.Message{Role: provider.RoleUser, Content: "healthy"}) |
| 410 | bad := filepath.Join(root, "broken.jsonl") |
| 411 | if err := os.WriteFile(bad, []byte("{broken\n"), 0o600); err != nil { |
| 412 | t.Fatal(err) |
| 413 | } |
| 414 | app := NewApp() |
| 415 | t.Cleanup(app.closeSessionServices) |
| 416 | if err := app.migrateDesktopSessionsV5(t.Context()); err == nil { |
| 417 | t.Fatal("broken source silently ignored") |
| 418 | } |
| 419 | if got := v5MigrationHistories(t, app); len(got) != 1 { |
| 420 | t.Fatalf("healthy sibling missing: %v", got) |
| 421 | } |
| 422 | writeMigrationJSON(t, bad, provider.Message{Role: provider.RoleUser, Content: "repaired"}) |
| 423 | app.closeSessionServices() |
| 424 | app = NewApp() |
| 425 | t.Cleanup(app.closeSessionServices) |
| 426 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 427 | t.Fatal(err) |
| 428 | } |
| 429 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 430 | if err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs |
| 434 | sort.Strings(ids) |
| 435 | if len(ids) != 2 { |
| 436 | t.Fatalf("retry failed: %v", ids) |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | func TestDesktopV5UpgradePairedCanonicalKeepsPriorAdoption(t *testing.T) { |
| 441 | for _, previousMigration := range []bool{false, true} { |
| 442 | t.Run(map[bool]string{false: "fresh", true: "already-continued"}[previousMigration], func(t *testing.T) { |
| 443 | isolateDesktopUserDirs(t) |
| 444 | path := filepath.Join(config.SessionDir(), "paired-canonical.jsonl") |
| 445 | writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "obsolete checkpoint"}) |
| 446 | root, id := config.SessionStoreDir(), agent.BranchID(path) |
| 447 | coldV4MigrationFixture(t, root, id) |
| 448 | app := NewApp() |
| 449 | t.Cleanup(app.closeSessionServices) |
| 450 | if previousMigration { |
| 451 | if err := app.migrateCanonicalStore(t.Context(), desktopMigrationSource{root: root, scope: "global"}); err != nil { |
| 452 | t.Fatal(err) |
| 453 | } |
| 454 | appendMigrationTestMessage(t, app.desktopSessionService(""), session.SessionRef{HostID: localDesktopHostID, SessionID: id}, "v5 continued") |
| 455 | } |
| 456 | before := migrationSourceSnapshot(t, desktopLegacySourceFiles(path, root)) |
| 457 | for range 2 { |
| 458 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 459 | t.Fatal(err) |
| 460 | } |
| 461 | } |
| 462 | history := v5MigrationHistories(t, app) |
| 463 | if len(history) != 1 || history[id][0] != "恢复完整对话" { |
| 464 | t.Fatalf("canonical authority lost or duplicated: %v", history) |
| 465 | } |
| 466 | if previousMigration && history[id][len(history[id])-1] != "v5 continued" { |
| 467 | t.Fatalf("continued work lost: %v", history) |
| 468 | } |
| 469 | assertMigrationSourceSnapshot(t, before) |
| 470 | }) |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | func TestDesktopV5UpgradeLegacyResumesWorkspacePublication(t *testing.T) { |
| 475 | isolateDesktopUserDirs(t) |
| 476 | path := filepath.Join(config.SessionDir(), "interrupted.jsonl") |
| 477 | writeMigrationJSON(t, path, provider.Message{Role: provider.RoleUser, Content: "durable history"}) |
| 478 | app := NewApp() |
| 479 | t.Cleanup(app.closeSessionServices) |
| 480 | err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{root: filepath.Dir(path), scope: "global"}, "missing-workspace") |
| 481 | if !errors.Is(err, workspacestate.ErrWorkspaceNotFound) { |
| 482 | t.Fatalf("expected attach failure: %v", err) |
| 483 | } |
| 484 | first := onlyMigrationRecord(t) |
| 485 | if first.Status != "failed" || first.TargetSessionID == "" { |
| 486 | t.Fatalf("missing retry receipt: %#v", first) |
| 487 | } |
| 488 | app.closeSessionServices() |
| 489 | app = NewApp() |
| 490 | t.Cleanup(app.closeSessionServices) |
| 491 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 492 | t.Fatal(err) |
| 493 | } |
| 494 | current := onlyMigrationRecord(t) |
| 495 | if current.Status != "completed" || current.TargetSessionID != first.TargetSessionID { |
| 496 | t.Fatalf("retry changed identity: %#v", current) |
| 497 | } |
| 498 | if got := v5MigrationHistories(t, app); len(got) != 1 { |
| 499 | t.Fatalf("interrupted import duplicated: %v", got) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | func TestDesktopV5UpgradeProjectV3FailureRetry(t *testing.T) { |
| 504 | isolateDesktopUserDirs(t) |
| 505 | workspace := filepath.Join(t.TempDir(), "历史项目") |
| 506 | if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil { |
| 507 | t.Fatal(err) |
| 508 | } |
| 509 | root := filepath.Join(filepath.Dir(config.ProjectSessionStoreDir(workspace)), "sessions-v3") |
| 510 | writeV3MigrationFixture(t, root, "healthy", session.FinalV31Codec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "healthy"}}) |
| 511 | writeV3MigrationFixture(t, root, "broken", session.PrototypeCodec, []provider.Message{{ID: "user", Role: provider.RoleUser, Content: "repaired"}}) |
| 512 | logPath := filepath.Join(root, "broken", "events.jsonl") |
| 513 | original, err := os.ReadFile(logPath) |
| 514 | if err != nil { |
| 515 | t.Fatal(err) |
| 516 | } |
| 517 | if err := os.WriteFile(logPath, []byte("{damaged}\n"), 0o600); err != nil { |
| 518 | t.Fatal(err) |
| 519 | } |
| 520 | app := NewApp() |
| 521 | t.Cleanup(app.closeSessionServices) |
| 522 | if err := app.migrateDesktopSessionsV5(t.Context()); err == nil { |
| 523 | t.Fatal("damaged preview accepted") |
| 524 | } |
| 525 | if got := v5MigrationHistories(t, app); len(got) != 1 { |
| 526 | t.Fatalf("healthy sibling missing: %v", got) |
| 527 | } |
| 528 | if err := os.WriteFile(logPath, original, 0o600); err != nil { |
| 529 | t.Fatal(err) |
| 530 | } |
| 531 | app.closeSessionServices() |
| 532 | app = NewApp() |
| 533 | t.Cleanup(app.closeSessionServices) |
| 534 | if err := app.migrateDesktopSessionsV5(t.Context()); err != nil { |
| 535 | t.Fatal(err) |
| 536 | } |
| 537 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 538 | if err != nil { |
| 539 | t.Fatal(err) |
| 540 | } |
| 541 | if got := state.Workspaces[desktopWorkspaceID("project", workspace)].SessionIDs; len(got) != 2 { |
| 542 | t.Fatalf("project v3 recovery=%v", got) |
| 543 | } |
| 544 | } |
| 545 |