| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "reasonix/desktop/internal/workspacestate" |
| 15 | "reasonix/internal/agent" |
| 16 | "reasonix/internal/provider" |
| 17 | ) |
| 18 | |
| 19 | func TestLegacyMigrationTranscriptFailurePreservesSourceAndContinues(t *testing.T) { |
| 20 | for _, mode := range []string{"legacy", "paired"} { |
| 21 | t.Run(mode, func(t *testing.T) { |
| 22 | isolateDesktopUserDirs(t) |
| 23 | oldVersion, oldEndpoint := version, crashEndpoint |
| 24 | version = "v9.9.9" |
| 25 | t.Cleanup(func() { version, crashEndpoint = oldVersion, oldEndpoint }) |
| 26 | var uploaded []crashReport |
| 27 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { |
| 28 | var report crashReport |
| 29 | if err := json.NewDecoder(request.Body).Decode(&report); err != nil { |
| 30 | t.Error(err) |
| 31 | } else { |
| 32 | uploaded = append(uploaded, report) |
| 33 | } |
| 34 | w.WriteHeader(http.StatusAccepted) |
| 35 | })) |
| 36 | defer server.Close() |
| 37 | crashEndpoint = server.URL |
| 38 | root := t.TempDir() |
| 39 | logPath := filepath.Join(root, "service.log") |
| 40 | logFile, err := os.Create(logPath) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | t.Cleanup(func() { _ = logFile.Close() }) |
| 45 | previousLogger := slog.Default() |
| 46 | slog.SetDefault(slog.New(slog.NewJSONHandler(logFile, nil))) |
| 47 | t.Cleanup(func() { slog.SetDefault(previousLogger) }) |
| 48 | legacyDir := filepath.Join(root, "sessions") |
| 49 | badPath := filepath.Join(legacyDir, "a-conflicting.jsonl") |
| 50 | bad := agent.NewSession("system") |
| 51 | bad.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "retained question"}) |
| 52 | bad.Add(provider.Message{ID: "result-one", Role: provider.RoleTool, ToolCallID: "reused-call", Content: "first result"}) |
| 53 | bad.Add(provider.Message{ID: "result-two", Role: provider.RoleTool, ToolCallID: "reused-call", Content: "second result"}) |
| 54 | if err := bad.Save(badPath); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | original, err := os.ReadFile(badPath) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | goodPath := filepath.Join(legacyDir, "b-healthy.jsonl") |
| 62 | good := agent.NewSession("system") |
| 63 | good.Add(provider.Message{ID: "healthy-user", Role: provider.RoleUser, Content: "healthy conversation"}) |
| 64 | if err := good.Save(goodPath); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | source := desktopMigrationSource{root: legacyDir, scope: "global"} |
| 68 | if mode == "paired" { |
| 69 | source.pairedRoot = filepath.Join(root, "sessions-v4") |
| 70 | } |
| 71 | var targetID string |
| 72 | // A fresh app exercises the next startup, including persisted |
| 73 | // migration bookkeeping, without touching the original source. |
| 74 | for attempt := range 2 { |
| 75 | app := NewApp() |
| 76 | app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id") |
| 77 | app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json")) |
| 78 | t.Cleanup(app.closeSessionServices) |
| 79 | err := app.migrateLegacyDirectory(t.Context(), source) |
| 80 | if err == nil || !strings.Contains(err.Error(), `duplicate transcript record "tool:reused-call"`) { |
| 81 | t.Fatalf("migration error = %v", err) |
| 82 | } |
| 83 | ledger, err := readDesktopMigrationLedger() |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | failed := ledger.Records[desktopLegacyMigrationKey(badPath)] |
| 88 | if failed.Status != "failed" || failed.ErrorCode != "legacy_import" { |
| 89 | t.Fatalf("failed migration was not recorded: %+v", failed) |
| 90 | } |
| 91 | completed := ledger.Records[desktopLegacyMigrationKey(goodPath)] |
| 92 | if completed.Status != "completed" || completed.TargetSessionID == "" { |
| 93 | t.Fatalf("healthy migration did not continue: %+v", completed) |
| 94 | } |
| 95 | if targetID != "" && targetID != completed.TargetSessionID { |
| 96 | t.Fatal("restart duplicated the healthy session") |
| 97 | } |
| 98 | targetID = completed.TargetSessionID |
| 99 | state, err := app.desktopSessions.workspaceState.Load(t.Context()) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | ids := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs |
| 104 | if len(ids) != 1 || ids[0] != targetID { |
| 105 | t.Fatalf("workspace sessions = %v", ids) |
| 106 | } |
| 107 | current, err := os.ReadFile(badPath) |
| 108 | if err != nil || !bytes.Equal(original, current) { |
| 109 | t.Fatalf("failed migration changed the source: %v", err) |
| 110 | } |
| 111 | logBody, err := os.ReadFile(logPath) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | for _, private := range []string{root, "retained question", "first result", "second result", "reused-call"} { |
| 116 | if bytes.Contains(logBody, []byte(private)) { |
| 117 | t.Fatalf("migration diagnostic exposed private value %q", private) |
| 118 | } |
| 119 | } |
| 120 | migrationLogs, runtimeLogs := 0, 0 |
| 121 | var sessionKey string |
| 122 | for line := range bytes.SplitSeq(bytes.TrimSpace(logBody), []byte("\n")) { |
| 123 | var entry struct { |
| 124 | Message string `json:"msg"` |
| 125 | SourceKey string `json:"source_key"` |
| 126 | Diagnostic struct { |
| 127 | SessionKey string `json:"session_key"` |
| 128 | Baseline struct{ Code string } `json:"baseline"` |
| 129 | } `json:"diagnostic"` |
| 130 | } |
| 131 | if err := json.Unmarshal(line, &entry); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | switch entry.Message { |
| 135 | case "session transcript initialization failed": |
| 136 | runtimeLogs++ |
| 137 | sessionKey = entry.Diagnostic.SessionKey |
| 138 | case "desktop session migration transcript initialization failed": |
| 139 | migrationLogs++ |
| 140 | if entry.SourceKey != failed.SourceKey || sessionKey == "" || entry.Diagnostic.SessionKey != sessionKey || entry.Diagnostic.Baseline.Code != "duplicate_record_identity" { |
| 141 | t.Fatalf("migration log cannot be correlated with runtime and ledger: %+v", entry) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | if migrationLogs != attempt+1 || runtimeLogs != attempt+1 { |
| 146 | t.Fatalf("missing diagnostics after restart: migration=%d runtime=%d", migrationLogs, runtimeLogs) |
| 147 | } |
| 148 | pending := pendingCrashQueuePaths() |
| 149 | if len(pending) != attempt+1 { |
| 150 | t.Fatalf("pending online diagnostics = %d, want %d", len(pending), attempt+1) |
| 151 | } |
| 152 | reportBody, err := os.ReadFile(pending[len(pending)-1]) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | var report crashReport |
| 157 | if err := json.Unmarshal(reportBody, &report); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | if report.Kind != "exception" || report.Source != "desktop.session_migration" || |
| 161 | report.Label != "transcript.initialization" || report.ErrorType != "TranscriptInitializationError" || |
| 162 | !strings.Contains(report.FingerprintHint, "duplicate_record_identity") || report.InstallID != "" { |
| 163 | t.Fatalf("online diagnostic is incomplete: %+v", report) |
| 164 | } |
| 165 | for _, private := range []string{root, failed.SourceKey, sessionKey, "retained question", "first result", "second result", "reused-call"} { |
| 166 | if bytes.Contains(reportBody, []byte(private)) { |
| 167 | t.Fatalf("online diagnostic exposed local value %q", private) |
| 168 | } |
| 169 | } |
| 170 | app.closeSessionServices() |
| 171 | } |
| 172 | NewApp().flushPendingCrash() |
| 173 | if len(uploaded) != 2 || uploaded[0].Source != "desktop.session_migration" || uploaded[1].Source != "desktop.session_migration" || len(pendingCrashPaths()) != 0 { |
| 174 | t.Fatalf("diagnostic delivery did not preserve both startup failures: uploaded=%+v pending=%v", uploaded, pendingCrashPaths()) |
| 175 | } |
| 176 | if uploaded[0].EventID == "" || uploaded[0].EventID == uploaded[1].EventID { |
| 177 | t.Fatalf("separate startup failures reused an event identity: uploaded=%+v", uploaded) |
| 178 | } |
| 179 | if uploaded[0].DedupKey == "" || uploaded[0].DedupKey != uploaded[1].DedupKey { |
| 180 | t.Fatalf("equivalent startup failures lost their correlation key: uploaded=%+v", uploaded) |
| 181 | } |
| 182 | }) |
| 183 | } |
| 184 | } |
| 185 |