返回 DeepSeek-Reasonix
historical_archive_transaction_test.go
根目录 / desktop / historical_archive_transaction_test.go
1 package main
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "reasonix/desktop/internal/workspacestate"
10 "reasonix/internal/config"
11 "reasonix/internal/identitylock"
12 "reasonix/internal/session"
13 )
14
15 func historicalArchiveFixture(t *testing.T) (*App, SessionSelector, *session.Service) {
16 t.Helper()
17 isolateDesktopUserDirs(t)
18 root := config.SessionStoreDir()
19 old := coldV4MigrationFixture(t, root, "archive-transaction")
20 app := newHistoricalLifecycleApp(t)
21 key := historicalLifecycleID(t, app, "archive-transaction")
22 return app, SessionSelector{Source: &SessionSourceRef{Path: filepath.Join(root, "archive-transaction"), SourceKey: key}}, old
23 }
24
25 func TestHistoricalArchiveTransactionRestart(t *testing.T) {
26 t.Run("conflicting-content-ready", func(t *testing.T) { checkConflictingArchiveReservation(t, false) })
27 t.Run("identical-content-ready", func(t *testing.T) { checkConflictingArchiveReservation(t, true) })
28 for _, stage := range []string{"content-published", "before-archive-commit", "unfinished-import"} {
29 t.Run(stage, func(t *testing.T) {
30 app, selector, _ := historicalArchiveFixture(t)
31 before := migrationSourceSnapshot(t, canonicalMigrationSourceFiles(config.SessionStoreDir(), "archive-transaction"))
32 if stage == "before-archive-commit" {
33 app.lifecycleCheckpointHook = func(phase string) {
34 if phase == stage {
35 panic("simulated exit")
36 }
37 }
38 func() {
39 defer func() {
40 if recover() == nil {
41 t.Error("missing interruption")
42 }
43 }()
44 _, _ = app.ArchiveSessionTarget(selector)
45 }()
46 } else {
47 app.desktopSessions.beforeMigrationRegistryCommit = func() error { return errors.New("simulated exit") }
48 var err error
49 if stage == "unfinished-import" {
50 _, err = app.ImportHistoricalSession(selector.Source.SourceKey)
51 } else {
52 _, err = app.ArchiveSessionTarget(selector)
53 }
54 if err == nil {
55 t.Fatal("missing interruption")
56 }
57 }
58 state, err := app.workspaceRegistry().Load(t.Context())
59 if err != nil {
60 t.Fatal(err)
61 }
62 if len(state.SourceMappings) != 0 || len(state.SessionStates) != 0 {
63 t.Fatal("staged archive was published as an active session")
64 }
65 if stage == "before-archive-commit" {
66 if _, err := app.ImportHistoricalSession(selector.Source.SourceKey); !historicalSourceBusyError(err) {
67 t.Fatalf("navigation stole an archive reservation: %v", err)
68 }
69 }
70 app.stopHistoricalImports()
71 app.closeSessionServices()
72 app = newHistoricalLifecycleApp(t)
73 result, err := app.ArchiveSessionTarget(selector)
74 if err != nil || !result.Committed {
75 t.Fatalf("resume: %+v, %v", result, err)
76 }
77 if err := app.recoverDesktopOperations(t.Context(), false); err != nil {
78 t.Fatal(err)
79 }
80 state, err = app.workspaceRegistry().Load(t.Context())
81 if err != nil || len(state.SessionStates) != 1 || len(state.SourceMappings) != 1 {
82 t.Fatalf("duplicate or missing archive: %+v, %v", state.SessionStates, err)
83 }
84 for _, status := range state.SessionStates {
85 if status.Lifecycle != workspacestate.Archived {
86 t.Fatalf("unexpected lifecycle: %+v", status)
87 }
88 }
89 assertMigrationSourceSnapshot(t, before)
90 })
91 }
92 }
93
94 func checkConflictingArchiveReservation(t *testing.T, identical bool) {
95 app, selector, old := historicalArchiveFixture(t)
96 ctx := t.Context()
97 base, err := app.ImportHistoricalSession(selector.Source.SourceKey)
98 if err != nil {
99 t.Fatal(err)
100 }
101 oldRef := session.SessionRef{HostID: "migration-source", SessionID: filepath.Base(selector.Source.Path)}
102 if identical {
103 if err := old.SetTitle(ctx, oldRef, "metadata only"); err != nil {
104 t.Fatal(err)
105 }
106 } else {
107 appendMigrationTestMessage(t, old, oldRef, "independent later work")
108 }
109 if err := app.ArchiveCanonicalSession(base.Session); err != nil {
110 t.Fatal(err)
111 }
112 if err := app.PurgeCanonicalSession(base.Session); err != nil {
113 t.Fatal(err)
114 }
115 fingerprint, err := desktopSourceFingerprint(selector.Source.Path)
116 if err != nil {
117 t.Fatal(err)
118 }
119 bundle := filepath.Join(t.TempDir(), "export")
120 if err := old.TryExportCold(ctx, oldRef, bundle); err != nil {
121 t.Fatal(err)
122 }
123 const reserved = "old-unversioned-reservation"
124 if _, err := app.desktopSessionService("").ImportWithHeader(ctx, bundle, session.CreateOptions{SessionID: reserved, CWD: globalWorkspaceRoot(), Origin: session.SessionOriginCanonicalImport}); err != nil {
125 t.Fatal(err)
126 }
127 if err := app.commitDesktopImport(ctx, desktopMigrationSource{scope: "global"}, selector.Source.Path, "canonical", fingerprint, reserved, base.WorkspaceID); !errors.Is(err, workspacestate.ErrMutationConflict) {
128 t.Fatalf("fixture admission: %v", err)
129 }
130 state, err := app.workspaceRegistry().Load(ctx)
131 if err != nil {
132 t.Fatal(err)
133 }
134 pending := pendingHistoricalOperation(state, selector.Source.SourceKey)
135 if pending == nil || pending.Phase != "content_ready" {
136 t.Fatal("missing interrupted import")
137 }
138 app.stopHistoricalImports()
139 app.closeSessionServices()
140 app = newHistoricalLifecycleApp(t)
141 result, err := app.ArchiveSessionTarget(selector)
142 if err != nil || !result.Committed {
143 t.Fatalf("archive interrupted conversion: %+v, %v", result, err)
144 }
145 state, err = app.workspaceRegistry().Load(ctx)
146 if err != nil || state.SessionStates[base.Session.SessionID].Lifecycle != workspacestate.Deleted {
147 t.Fatalf("original tombstone changed: %v", err)
148 }
149 if identical {
150 if result.Outcome != "already_removed" || len(state.SessionStates) != 1 {
151 t.Fatalf("identical staged copy was resurrected: %+v", result)
152 }
153 } else if state.PendingOperations[pending.ID].Phase != "committed" || state.SessionStates[reserved].Lifecycle != workspacestate.Archived || len(state.SessionStates) != 2 {
154 t.Fatalf("reservation was duplicated or not completed: %+v", state.PendingOperations[pending.ID])
155 }
156 }
157
158 func TestHistoricalArchiveRejectsWriterAndSourceChange(t *testing.T) {
159 app, selector, _ := historicalArchiveFixture(t)
160 release, err := identitylock.TryAcquire(filepath.Join(selector.Source.Path, "writer.lock"))
161 if err != nil {
162 t.Fatal(err)
163 }
164 result, err := app.ArchiveSessionTarget(selector)
165 release()
166 if err == nil || result.Committed {
167 t.Fatal("archive ignored source writer")
168 }
169 state, err := app.workspaceRegistry().Load(t.Context())
170 if err != nil || len(state.PendingOperations) != 0 {
171 t.Fatal("busy source admitted an operation")
172 }
173 // A source change outside the cooperative writer protocol is still caught
174 // by the fingerprint fence before the parent publishes its staged content.
175 app.desktopSessions.beforeMigrationRegistryCommit = func() error {
176 path := filepath.Join(selector.Source.Path, "manifest.json")
177 body, err := os.ReadFile(path)
178 if err != nil {
179 return err
180 }
181 return os.WriteFile(path, append(body, '\n'), 0600)
182 }
183 if result, err := app.ArchiveSessionTarget(selector); err == nil || result.Committed {
184 t.Fatal("failed validation was reported committed")
185 }
186 }
187
188 func TestHistoricalArchiveDiscoversLaterWorkWithoutDuplicateMetadataCopy(t *testing.T) {
189 app, selector, old := historicalArchiveFixture(t)
190 first, err := app.ArchiveSessionTarget(selector)
191 if err != nil {
192 t.Fatal(err)
193 }
194 oldRef := session.SessionRef{HostID: "migration-source", SessionID: "archive-transaction"}
195 if err := old.SetTitle(t.Context(), oldRef, "New title only"); err != nil {
196 t.Fatal(err)
197 }
198 second, err := app.ArchiveSessionTarget(selector)
199 if err != nil || second.TargetKey != first.TargetKey {
200 t.Fatalf("metadata created a copy: %+v, %v", second, err)
201 }
202 appendMigrationTestMessage(t, old, oldRef, "later independent work")
203 if _, err := app.ListHistoricalSessions(); err != nil {
204 t.Fatal(err)
205 }
206 state, err := app.workspaceRegistry().Load(t.Context())
207 if err != nil {
208 t.Fatal(err)
209 }
210 rows := app.historicalCanonicalTopicsFromProjection("global", "", state, workspacestate.NewWorkspaceIndex(state))
211 if len(rows) != 0 {
212 t.Fatalf("an adopted source must not return as a second ordinary conversation: %+v", rows)
213 }
214 third, err := app.ArchiveSessionTarget(selector)
215 if err != nil || third.TargetKey == first.TargetKey || third.Outcome != "archived_copy" {
216 t.Fatalf("later work: %+v, %v", third, err)
217 }
218 if err := old.SetTitle(t.Context(), oldRef, "Another metadata change"); err != nil {
219 t.Fatal(err)
220 }
221 fourth, err := app.ArchiveSessionTarget(selector)
222 if err != nil || fourth.TargetKey != third.TargetKey {
223 t.Fatalf("metadata duplicated the version: %+v, %v", fourth, err)
224 }
225 state, err = app.workspaceRegistry().Load(t.Context())
226 if err != nil || len(state.SessionStates) != 2 {
227 t.Fatalf("expected two distinct histories: %+v, %v", state.SessionStates, err)
228 }
229 }
230
231 func TestHistoricalArchiveTopicUsesSourceTransaction(t *testing.T) {
232 app, selector, _ := historicalArchiveFixture(t)
233 if err := app.saveHistoricalSourcePresentation(selector.Source.SourceKey, func(p *historicalSourcePresentation) {
234 pinned := true
235 p.Title, p.Pinned = "User's historical title", &pinned
236 }); err != nil {
237 t.Fatal(err)
238 }
239 if err := app.TrashTopic("historical-" + selector.Source.SourceKey); err != nil {
240 t.Fatal(err)
241 }
242 state, err := app.workspaceRegistry().Load(t.Context())
243 if err != nil {
244 t.Fatal(err)
245 }
246 mapping, ok, err := state.ResolveSource(selector.Source.SourceKey)
247 if err != nil || !ok || state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Archived {
248 t.Fatalf("topic did not archive source: %v", err)
249 }
250 if !state.Presentation[mapping.SessionID].Pinned {
251 t.Fatal("archive lost the historical pin")
252 }
253 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}
254 if err := app.RestoreCanonicalSession(ref); err != nil {
255 t.Fatal(err)
256 }
257 info, err := app.desktopSessionService("").Query().Stat(t.Context(), ref)
258 if err != nil || info.Title != "User's historical title" {
259 t.Fatalf("restore lost the historical title: %+v, %v", info, err)
260 }
261 }
262
263 func TestHistoricalArchiveDoesNotInferOriginFromDeletedID(t *testing.T) {
264 app, selector, old := historicalArchiveFixture(t)
265 ctx := t.Context()
266 id := filepath.Base(selector.Source.Path)
267 workspace, err := app.ensureDesktopWorkspace(ctx, "global", "")
268 if err != nil {
269 t.Fatal(err)
270 }
271 registry := app.workspaceRegistry()
272 if err := registry.AttachSession(ctx, "", workspace, id, ""); err != nil {
273 t.Fatal(err)
274 }
275 if err := registry.ArchiveSession(ctx, id); err != nil {
276 t.Fatal(err)
277 }
278 state, err := registry.Load(ctx)
279 if err != nil {
280 t.Fatal(err)
281 }
282 if err := registry.BeginPurge(ctx, id, state.Generation); err != nil {
283 t.Fatal(err)
284 }
285 digest, err := canonicalMigrationDigest(ctx, old.Query(), session.SessionRef{HostID: "migration-source", SessionID: id})
286 if err != nil {
287 t.Fatal(err)
288 }
289 fingerprint, err := desktopSourceFingerprint(selector.Source.Path)
290 if err != nil {
291 t.Fatal(err)
292 }
293 _, _, err = app.resolveDesktopImportTarget(ctx, app.desktopSessionService("").Query(), id, "ledger-key", selector.Source.SourceKey, digest, selector.Source.Path, fingerprint)
294 var operationErr *SessionOperationError
295 if !errors.As(err, &operationErr) || operationErr.Code != "source_ambiguous" {
296 t.Fatalf("unproven deletion was bypassed: %v", err)
297 }
298 }
299
300 func TestHistoricalArchiveReceiptPreservesIndependentHeads(t *testing.T) {
301 isolateDesktopUserDirs(t)
302 root := config.SessionStoreDir()
303 dir := filepath.Join(root, "head-copy")
304 legacy := filepath.Join(config.SessionDir(), "heads.jsonl")
305 writeMigrationJSON(t, filepath.Join(dir, "manifest.json"), session.Manifest{Codec: session.Codec, SessionID: "head-copy", Source: &session.Source{Path: legacy, Version: "legacy", LegacyHeadID: "other"}})
306 base := desktopLegacyMigrationKey(legacy)
307 if _, err := saveDesktopMigrationHeads(base, []string{"main", "other"}, "other", "revision", []desktopMigrationConversion{{Root: root, SessionID: "head-copy", HeadID: "other"}}); err != nil {
308 t.Fatal(err)
309 }
310 if err := updateDesktopMigrationLedger(base, "primary-target", "completed", "", "same-history"); err != nil {
311 t.Fatal(err)
312 }
313 if err := updateDesktopMigrationLedger(desktopLegacyHeadKey(legacy, "other"), "independent-target", "completed", "", "same-history"); err != nil {
314 t.Fatal(err)
315 }
316 receipts, err := canonicalConversionReceipts(t.Context(), dir)
317 if err != nil || len(receipts) == 0 {
318 t.Fatalf("head receipts: %+v, %v", receipts, err)
319 }
320 for _, receipt := range receipts {
321 if receipt.TargetSessionID != "independent-target" {
322 t.Fatalf("primary head consumed an independent head: %+v", receipt)
323 }
324 }
325 }
326
326 lines GO