返回 DeepSeek-Reasonix
historical_source_branch_test.go
根目录 / desktop / historical_source_branch_test.go
1 package main
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/config"
12 "reasonix/internal/identitylock"
13 "reasonix/internal/provider"
14 "reasonix/internal/session"
15 )
16
17 func TestHistoricalSourceVersionResumesPublishedBranch(t *testing.T) {
18 for _, test := range []struct {
19 name, format string
20 restart, replay bool
21 }{
22 {"canonical/retry", "canonical", false, false},
23 {"canonical/restart", "canonical", true, false},
24 {"canonical/replay", "canonical", true, true},
25 {"legacy/retry", "legacy", false, false},
26 {"legacy/restart", "legacy", true, false},
27 {"legacy/replay", "legacy", true, true},
28 } {
29 t.Run(test.name, func(t *testing.T) {
30 isolateDesktopUserDirs(t)
31 var changeSource func()
32 if test.format == "canonical" {
33 old := coldV4MigrationFixture(t, config.SessionStoreDir(), "metadata-update")
34 changeSource = func() {
35 if err := old.SetTitle(t.Context(), session.SessionRef{HostID: "migration-source", SessionID: "metadata-update"}, "Updated source title"); err != nil {
36 t.Fatal(err)
37 }
38 }
39 } else {
40 if err := os.MkdirAll(config.SessionDir(), 0700); err != nil {
41 t.Fatal(err)
42 }
43 path := writeLegacySession(t, config.SessionDir(), "metadata-update.jsonl", "same messages", time.Now())
44 changeSource = func() {
45 body, err := os.ReadFile(path)
46 if err != nil {
47 t.Fatal(err)
48 }
49 if err := os.WriteFile(path, append(body, '\n'), 0600); err != nil {
50 t.Fatal(err)
51 }
52 }
53 }
54 app := newHistoricalLifecycleApp(t)
55 listed, err := app.ListHistoricalSessions()
56 if err != nil || len(listed.Items) != 1 {
57 t.Fatalf("list: %+v %v", listed, err)
58 }
59 id := listed.Items[0].ID
60 base, err := app.ImportHistoricalSession(id)
61 if err != nil {
62 t.Fatal(err)
63 }
64 changeSource()
65 _, source, err := app.historicalSourceForSelector(SessionSelector{Ref: &base.Session})
66 if err != nil {
67 t.Fatal(err)
68 }
69 update := app.checkHistoricalSourceUpdate(t.Context(), id, source)
70 if update.Status != "available" || update.Source == nil {
71 t.Fatalf("updated source: %+v", update)
72 }
73 // Simulate interruption after publishing the branch's content but
74 // before publishing its workspace mapping, then retry with either the
75 // existing coordinator or a fresh process's durable state.
76 app.desktopSessions.beforeMigrationRegistryCommit = func() error { return errors.New("injected registry interruption") }
77 prepared, err := app.PrepareHistoricalSourceVersion(*update.Source, update.Version)
78 if err != nil {
79 t.Fatal(err)
80 }
81 app.historicalImports.mu.Lock()
82 call := app.historicalImports.operations[prepared.OperationID]
83 app.historicalImports.mu.Unlock()
84 if _, err := waitHistoricalImport(call); err == nil {
85 t.Fatal("expected registry interruption")
86 }
87 failed, err := app.GetSessionPreparation(prepared.OperationID)
88 if err != nil || failed.Status != "failed" || !failed.Retryable {
89 t.Fatalf("interrupted import: %+v %v", failed, err)
90 }
91 before, err := app.workspaceRegistry().Load(t.Context())
92 if err != nil {
93 t.Fatal(err)
94 }
95 pending := pendingHistoricalOperation(before, call.sourceKey)
96 if pending == nil || len(pending.SessionIDs) != 1 || pending.Phase != "prepared" {
97 t.Fatalf("missing reserved branch: %+v", pending)
98 }
99 reservedID := pending.SessionIDs[0]
100 app.desktopSessions.beforeMigrationRegistryCommit = nil
101 if test.restart {
102 app.stopHistoricalImports()
103 app.closeSessionServices()
104 app = newHistoricalLifecycleApp(t)
105 }
106 if test.replay {
107 if err := app.recoverDesktopSessionOperations(t.Context()); err != nil {
108 t.Fatalf("explicit recovery of version import: %v", err)
109 }
110 }
111 prepared, err = app.PrepareHistoricalSourceVersion(*update.Source, update.Version)
112 if err != nil {
113 t.Fatal(err)
114 }
115 app.historicalImports.mu.Lock()
116 call = app.historicalImports.operations[prepared.OperationID]
117 app.historicalImports.mu.Unlock()
118 result, err := waitHistoricalImport(call)
119 if err != nil {
120 t.Fatalf("retry of published branch: %v", err)
121 }
122 if result.Session == base.Session {
123 t.Fatalf("explicit branch import reopened the current session: %+v", result.Session)
124 }
125 if result.Session.SessionID != reservedID {
126 t.Fatalf("retry replaced reserved branch %q with %q", reservedID, result.Session.SessionID)
127 }
128 after, err := app.workspaceRegistry().Load(t.Context())
129 if err != nil {
130 t.Fatal(err)
131 }
132 if after.PendingOperations[pending.ID].Phase != "committed" || len(after.SourceMappings) != 2 {
133 t.Fatalf("retry did not commit exactly one branch: %+v", after.SourceMappings)
134 }
135 unchanged, err := desktopSourceFingerprint(update.Source.Path)
136 if err != nil || unchanged != update.Version {
137 t.Fatalf("import modified the source: %v", err)
138 }
139 baseHistory, err := app.desktopSessionService("").Query().History(t.Context(), base.Session)
140 if err != nil || len(baseHistory) != 1 {
141 t.Fatalf("original conversation lost: %v", err)
142 }
143 branchHistory, err := app.desktopSessionService("").Query().History(t.Context(), result.Session)
144 if err != nil || len(branchHistory) != 1 || branchHistory[0].Content != baseHistory[0].Content {
145 t.Fatalf("branch history lost: %+v %v", branchHistory, err)
146 }
147 })
148 }
149 }
150
151 func TestHistoricalSourceVersionOperationDoesNotCollideWithOrdinaryImport(t *testing.T) {
152 isolateDesktopUserDirs(t)
153 const id = "reserved-source"
154 coldV4MigrationFixture(t, config.SessionStoreDir(), id)
155 app := newHistoricalLifecycleApp(t)
156 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
157 if err != nil {
158 t.Fatal(err)
159 }
160 path := filepath.Join(config.SessionStoreDir(), id)
161 fingerprint, err := desktopSourceFingerprint(path)
162 if err != nil {
163 t.Fatal(err)
164 }
165 ordinary := desktopMigrationSource{scope: "global"}
166 baseOp, err := app.prepareDesktopImport(t.Context(), ordinary, path, fingerprint, "reserved-target", workspace)
167 if err != nil {
168 t.Fatal(err)
169 }
170 version := desktopMigrationSource{scope: "global", versionFingerprint: fingerprint}
171 branchOp, err := app.prepareDesktopImport(t.Context(), version, path, fingerprint, "reserved-target", workspace)
172 if err != nil {
173 t.Fatalf("version import collided with ordinary reservation: %v", err)
174 }
175 if branchOp == baseOp {
176 t.Fatal("distinct mappings reused the same operation")
177 }
178 again, err := app.prepareDesktopImport(t.Context(), version, path, fingerprint, "reserved-target", workspace)
179 if err != nil || again != branchOp {
180 t.Fatalf("version reservation is not idempotent: %q %v", again, err)
181 }
182 state, err := app.workspaceRegistry().Load(t.Context())
183 if err != nil {
184 t.Fatal(err)
185 }
186 if state.PendingOperations[baseOp].Mapping.SourceKey != ordinary.mappingKey(path) || state.PendingOperations[branchOp].Mapping.SourceKey != version.mappingKey(path) {
187 t.Fatal("reservation changed another import's mapping")
188 }
189 }
190
191 func TestHistoricalSourceVersionResumesPreviousOperationID(t *testing.T) {
192 isolateDesktopUserDirs(t)
193 const id = "previous-version-source"
194 coldV4MigrationFixture(t, config.SessionStoreDir(), id)
195 app := newHistoricalLifecycleApp(t)
196 workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
197 if err != nil {
198 t.Fatal(err)
199 }
200 path := filepath.Join(config.SessionStoreDir(), id)
201 fingerprint, err := desktopSourceFingerprint(path)
202 if err != nil {
203 t.Fatal(err)
204 }
205 source := desktopMigrationSource{scope: "global", versionFingerprint: fingerprint}
206 oldID := "import-" + desktopSourceKey(path, "") + "-" + fingerprint
207 if err := app.workspaceRegistry().BeginOperation(t.Context(), workspacestate.Operation{
208 ID: oldID, Kind: "import", WorkspaceID: workspace, SessionIDs: []string{"reserved-target"}, Lifecycle: workspacestate.Active,
209 Mapping: &workspacestate.SourceMapping{SourceKey: source.mappingKey(path), Path: path, Fingerprint: fingerprint, SessionID: "reserved-target", WorkspaceID: workspace},
210 }); err != nil {
211 t.Fatal(err)
212 }
213 opID, err := app.prepareDesktopImport(t.Context(), source, path, fingerprint, "reserved-target", workspace)
214 if err != nil || opID != oldID {
215 t.Fatalf("previous version reservation replaced: %q %v", opID, err)
216 }
217 state, err := app.workspaceRegistry().Load(t.Context())
218 if err != nil || len(state.PendingOperations) != 1 {
219 t.Fatalf("previous version import duplicated: %v", err)
220 }
221 }
222
223 // Older migrations could publish another target for an already adopted source
224 // under its unversioned key. Committing that operation must stay forbidden, but
225 // it must not prevent an explicit version import from opening a separate branch.
226 func TestHistoricalSourceVersionWithConflictingContentReadyImport(t *testing.T) {
227 for _, scenario := range []string{"ready", "main_head", "target_changed", "target_busy", "source_busy"} {
228 t.Run(scenario, func(t *testing.T) { testHistoricalConflictingVersion(t, scenario) })
229 }
230 }
231
232 func testHistoricalConflictingVersion(t *testing.T, scenario string) {
233 isolateDesktopUserDirs(t)
234 const id = "already-adopted"
235 old := coldV4MigrationFixture(t, config.SessionStoreDir(), id)
236 app := newHistoricalLifecycleApp(t)
237 listed, err := app.ListHistoricalSessions()
238 if err != nil || len(listed.Items) != 1 {
239 t.Fatalf("list: %+v %v", listed, err)
240 }
241 base, err := app.ImportHistoricalSession(listed.Items[0].ID)
242 if err != nil {
243 t.Fatal(err)
244 }
245 path := filepath.Join(config.SessionStoreDir(), id)
246 ordinary := desktopMigrationSource{scope: "global"}
247 if scenario == "main_head" {
248 ordinary.headID = "main"
249 initial, err := app.workspaceRegistry().Load(t.Context())
250 if err != nil {
251 t.Fatal(err)
252 }
253 mapping := initial.SourceMappings[desktopSourceKey(path, "")]
254 mapping.SourceKey, mapping.HeadID = ordinary.mappingKey(path), ordinary.headID
255 if err := app.workspaceRegistry().RecordSource(t.Context(), mapping, workspacestate.Presentation{}); err != nil {
256 t.Fatal(err)
257 }
258 }
259 oldRef := session.SessionRef{HostID: "migration-source", SessionID: id}
260 if err := old.SetTitle(t.Context(), oldRef, "Changed historical title"); err != nil {
261 t.Fatal(err)
262 }
263 fingerprint, err := desktopSourceFingerprint(path)
264 if err != nil {
265 t.Fatal(err)
266 }
267 // Reproduce the supplied metadata's old mapping plus content_ready target.
268 bundle := filepath.Join(t.TempDir(), "export")
269 if err := old.TryExportCold(t.Context(), oldRef, bundle); err != nil {
270 t.Fatal(err)
271 }
272 const reserved = "uncommitted-historical-target"
273 if _, err := app.desktopSessionService("").ImportWithHeader(t.Context(), bundle, session.CreateOptions{
274 SessionID: reserved, CWD: globalWorkspaceRoot(), Origin: session.SessionOriginCanonicalImport,
275 }); err != nil {
276 t.Fatal(err)
277 }
278 if err := app.commitDesktopImport(t.Context(), ordinary, path, "canonical", fingerprint, reserved, base.WorkspaceID); !errors.Is(err, workspacestate.ErrMutationConflict) {
279 t.Fatalf("conflicting old import = %v, want mutation conflict", err)
280 }
281 state, err := app.workspaceRegistry().Load(t.Context())
282 if err != nil {
283 t.Fatal(err)
284 }
285 pending := pendingHistoricalOperation(state, ordinary.mappingKey(path))
286 if pending == nil || pending.Phase != "content_ready" || pending.Mapping.SessionID != reserved {
287 t.Fatalf("missing content_ready import: %+v", pending)
288 }
289 if scenario == "target_changed" {
290 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: reserved}
291 binding, err := app.desktopSessionService("").Open(t.Context(), ref)
292 if err != nil {
293 t.Fatal(err)
294 }
295 appendSessionTestMessage(t, binding.Runtime(), "changed", provider.Message{ID: "changed", Role: provider.RoleUser, Content: "Different target history"})
296 if err := binding.Release(t.Context()); err != nil {
297 t.Fatal(err)
298 }
299 if err := app.desktopSessionService("").Close(t.Context(), ref); err != nil {
300 t.Fatal(err)
301 }
302 }
303 // A restart must not turn the pending replacement into permission to
304 // overwrite the original source mapping or its continued conversation.
305 app.stopHistoricalImports()
306 app.closeSessionServices()
307 app = newHistoricalLifecycleApp(t)
308 _, source, err := app.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{Path: path, HeadID: ordinary.headID}})
309 if err != nil {
310 t.Fatal(err)
311 }
312 update := app.checkHistoricalSourceUpdate(t.Context(), ordinary.mappingKey(path), source)
313 if update.Status != "available" || update.Source == nil {
314 t.Fatalf("updated source: %+v", update)
315 }
316 request := func() (SessionRestoreResult, error) {
317 prepared, err := app.PrepareHistoricalSourceVersion(*update.Source, update.Version)
318 if err != nil {
319 return SessionRestoreResult{}, err
320 }
321 app.historicalImports.mu.Lock()
322 call := app.historicalImports.operations[prepared.OperationID]
323 app.historicalImports.mu.Unlock()
324 return waitHistoricalImport(call)
325 }
326 if scenario == "target_busy" || scenario == "source_busy" {
327 var release func()
328 if scenario == "source_busy" {
329 // Freeze the writer lock without advancing the source generation;
330 // a real new generation correctly requires checking updates again.
331 release, err = identitylock.TryAcquire(filepath.Join(path, "writer.lock"))
332 if err != nil {
333 t.Fatal(err)
334 }
335 } else {
336 service := app.desktopSessionService("")
337 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: reserved}
338 binding, err := service.Open(t.Context(), ref)
339 if err != nil {
340 t.Fatal(err)
341 }
342 release = func() {
343 if err := binding.Release(t.Context()); err != nil {
344 t.Error(err)
345 }
346 if err := service.Close(t.Context(), ref); err != nil {
347 t.Error(err)
348 }
349 }
350 }
351 defer func() {
352 if release != nil {
353 release()
354 }
355 }()
356 if _, err := request(); !historicalSourceBusyError(err) {
357 t.Fatalf("owned session was not blocked: %v", err)
358 }
359 unchanged, _ := app.workspaceRegistry().Load(t.Context())
360 if unchanged.PendingOperations[pending.ID].Phase != "content_ready" || len(unchanged.SourceMappings) != len(state.SourceMappings) {
361 t.Fatal("busy recovery changed adoption")
362 }
363 release()
364 release = nil
365 }
366 result, err := request()
367 if scenario == "target_changed" {
368 if !errors.Is(err, workspacestate.ErrMutationConflict) {
369 t.Fatalf("changed target admitted: %v", err)
370 }
371 unchanged, _ := app.workspaceRegistry().Load(t.Context())
372 if unchanged.PendingOperations[pending.ID].Phase != "content_ready" || len(unchanged.SourceMappings) != len(state.SourceMappings) {
373 t.Fatal("changed target recovery modified adoption")
374 }
375 return
376 }
377 if err != nil {
378 t.Fatalf("version import with conflicting old reservation: %v", err)
379 }
380 if result.Session == base.Session {
381 t.Fatal("branch import reopened the original")
382 }
383 if result.Session.SessionID != reserved {
384 t.Fatal("branch import duplicated the validated content_ready target")
385 }
386 after, err := app.workspaceRegistry().Load(t.Context())
387 if err != nil {
388 t.Fatal(err)
389 }
390 if after.SourceMappings[ordinary.mappingKey(path)].SessionID != base.Session.SessionID {
391 t.Fatal("branch replaced the original source mapping")
392 }
393 versionKey := ordinary.mappingKey(path) + ":review:" + fingerprint
394 if after.SourceMappings[versionKey].SessionID != result.Session.SessionID {
395 t.Fatal("branch version mapping is missing")
396 }
397 if after.PendingOperations[pending.ID].Phase != "committed" {
398 t.Fatal("old operation was not completed")
399 }
400 app.stopHistoricalImports()
401 app.closeSessionServices()
402 app = newHistoricalLifecycleApp(t)
403 again, err := request()
404 if err != nil || again.Session != result.Session {
405 t.Fatalf("restart duplicated or lost recovered branch: %+v %v", again, err)
406 }
407 for _, ref := range []session.SessionRef{base.Session, result.Session} {
408 history, err := app.desktopSessionService("").Query().History(t.Context(), ref)
409 if err != nil || len(history) != 1 {
410 t.Fatalf("conversation %s unreadable: %v", ref.SessionID, err)
411 }
412 }
413 }
414
414 lines GO