返回 DeepSeek-Reasonix
lifecycle_test.go
根目录 / desktop / internal / workspacestate / lifecycle_test.go
1 package workspacestate
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 )
11
12 func TestDiscoveryRechecksConcurrentWorkspaceOwner(t *testing.T) {
13 path := filepath.Join(t.TempDir(), "state.json")
14 scanner, writer := NewStore(path), NewStore(path)
15 root := t.TempDir()
16 // The scanner derived an ID before another writer registered this root.
17 candidate := Workspace{ID: "derived-after-upgrade", Root: root, Title: "stale", Visible: true}
18 if err := writer.EnsureWorkspace(t.Context(), Workspace{ID: "persisted-owner", Root: root, Title: "User title", Visible: false}); err != nil {
19 t.Fatal(err)
20 }
21 if err := scanner.ReconcileDiscoveredSession(t.Context(), RecoveryEntry{ID: "discovered", SessionID: "orphan"}, &candidate); err != nil {
22 t.Fatal(err)
23 }
24 state, err := NewStore(path).Load(t.Context())
25 if err != nil {
26 t.Fatal(err)
27 }
28 owner := state.Workspaces["persisted-owner"]
29 if len(state.Workspaces) != 1 || len(owner.SessionIDs) != 1 || owner.SessionIDs[0] != "orphan" || owner.Title != "User title" || owner.Visible {
30 t.Fatalf("discovery replaced physical ownership or presentation: %+v", state.Workspaces)
31 }
32 }
33
34 func TestDiscoveryRejectsWorkspaceIDForAnotherDirectory(t *testing.T) {
35 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
36 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "collision", Root: t.TempDir()}); err != nil {
37 t.Fatal(err)
38 }
39 err := store.ReconcileDiscoveredSession(t.Context(), RecoveryEntry{ID: "discovered", SessionID: "orphan"}, &Workspace{ID: "collision", Root: t.TempDir()})
40 if !errors.Is(err, ErrMutationConflict) {
41 t.Fatalf("discovery attached session to a different directory: %v", err)
42 }
43 state, err := store.Load(t.Context())
44 if err != nil {
45 t.Fatal(err)
46 }
47 if len(state.Workspaces["collision"].SessionIDs) != 0 {
48 t.Fatal("failed discovery changed session ownership")
49 }
50 }
51
52 func TestDiscoveryRechecksConcurrentOwnership(t *testing.T) {
53 for _, phase := range []string{"prepared", "attached", "archived"} {
54 t.Run(phase, func(t *testing.T) {
55 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
56 ctx := t.Context()
57 if err := store.EnsureWorkspace(ctx, Workspace{ID: "global", Root: t.TempDir(), Visible: true}); err != nil {
58 t.Fatal(err)
59 }
60 // A scanner has already observed an empty registry. A different
61 // writer publishes/reserves the same ID before its scan finishes.
62 if err := store.BeginCreate(ctx, PendingCreate{OperationID: "create", WorkspaceID: "global", SessionID: "new"}); err != nil {
63 t.Fatal(err)
64 }
65 if phase != "prepared" {
66 if err := store.AttachSession(ctx, "create", "global", "new", ""); err != nil {
67 t.Fatal(err)
68 }
69 }
70 if phase == "archived" {
71 if err := store.ArchiveSession(ctx, "new"); err != nil {
72 t.Fatal(err)
73 }
74 }
75 before, _ := store.Load(ctx)
76 entry := RecoveryEntry{ID: "canonical-new", SessionID: "new", Format: "canonical", Status: "pending", Reason: "workspace_conflict"}
77 for _, workspace := range []*Workspace{nil, {ID: "wrong", Root: t.TempDir(), Visible: true}} {
78 if err := store.ReconcileDiscoveredSession(ctx, entry, workspace); err != nil {
79 t.Fatal(err)
80 }
81 }
82 after, _ := store.Load(ctx)
83 if after.Generation != before.Generation || len(after.RecoveryEntries) != 0 || len(after.Workspaces) != 1 {
84 t.Fatalf("stale discovery changed managed session: %+v", after)
85 }
86 })
87 }
88 }
89
90 func TestSessionTopicSurvivesReopenWithoutOverwritingPresentation(t *testing.T) {
91 path := filepath.Join(t.TempDir(), "state.json")
92 store := NewStore(path)
93 ctx := t.Context()
94 if err := store.EnsureWorkspace(ctx, Workspace{ID: "global", Root: t.TempDir()}); err != nil {
95 t.Fatal(err)
96 }
97 if err := store.AttachSession(ctx, "", "global", "session", ""); err != nil {
98 t.Fatal(err)
99 }
100 if err := store.EnsureSessionTopic(ctx, "session", "topic", "Initial"); err != nil {
101 t.Fatal(err)
102 }
103 title, pinned := "Edited", true
104 if err := store.UpdatePresentation(ctx, []string{"session"}, &title, &pinned); err != nil {
105 t.Fatal(err)
106 }
107 reopened := NewStore(path)
108 if err := reopened.EnsureSessionTopic(ctx, "session", "stale-tab-topic", "Stale"); err != nil {
109 t.Fatal(err)
110 }
111 state, err := reopened.Load(ctx)
112 if err != nil {
113 t.Fatal(err)
114 }
115 value := state.Presentation["session"]
116 if value.TopicID != "topic" || value.Title != title || !value.Pinned {
117 t.Fatalf("presentation overwritten: %+v", value)
118 }
119 }
120
121 func TestPurgeTombstoneAndRestoreAreOrderedByDurableCommit(t *testing.T) {
122 path := filepath.Join(t.TempDir(), "state.json")
123 first, second := NewStore(path), NewStore(path)
124 ctx := t.Context()
125 if err := first.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
126 t.Fatal(err)
127 }
128 if err := first.AttachSession(ctx, "", GlobalWorkspaceID, "victim", ""); err != nil {
129 t.Fatal(err)
130 }
131 if err := first.ArchiveSession(ctx, "victim"); err != nil {
132 t.Fatal(err)
133 }
134 state, err := first.Load(ctx)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if err := first.BeginPurge(ctx, "victim", state.Generation); err != nil {
139 t.Fatal(err)
140 }
141 state, err = second.Load(ctx)
142 if err != nil {
143 t.Fatal(err)
144 }
145 if state.SessionStates["victim"].Lifecycle != Deleted || ClassifyPurge(state, "victim") != PurgeTombstoned {
146 t.Fatalf("purge commit was not atomic: lifecycle=%+v purge=%v", state.SessionStates["victim"], ClassifyPurge(state, "victim"))
147 }
148 if err := second.RestoreSession(ctx, "victim"); !errors.Is(err, ErrMutationConflict) {
149 t.Fatalf("restore after tombstone = %v, want mutation conflict", err)
150 }
151 }
152
153 func TestRestoreClearsLegacyPreparedPurgeBeforeRearchive(t *testing.T) {
154 path := filepath.Join(t.TempDir(), "state.json")
155 first, second := NewStore(path), NewStore(path)
156 ctx := t.Context()
157 if err := first.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
158 t.Fatal(err)
159 }
160 if err := first.AttachSession(ctx, "", GlobalWorkspaceID, "victim", ""); err != nil {
161 t.Fatal(err)
162 }
163 if err := first.ArchiveSession(ctx, "victim"); err != nil {
164 t.Fatal(err)
165 }
166 var legacy Operation
167 if err := first.mutate(ctx, func(state *State) error {
168 status := state.SessionStates["victim"]
169 legacy = Operation{ID: "purge-victim", Kind: "purge", Phase: "prepared", Lifecycle: Deleted, SessionIDs: []string{"victim"}, ExpectedGeneration: status.Generation}
170 state.PendingOperations[legacy.ID] = legacy
171 return nil
172 }); err != nil {
173 t.Fatal(err)
174 }
175 if err := second.RestoreSession(ctx, "victim"); err != nil {
176 t.Fatal(err)
177 }
178 state, err := first.Load(ctx)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if state.SessionStates["victim"].Lifecycle != Active || ClassifyPurge(state, "victim") != PurgeAbsent {
183 t.Fatalf("restore did not supersede prepared purge: lifecycle=%+v purge=%v", state.SessionStates["victim"], ClassifyPurge(state, "victim"))
184 }
185 if err := first.ArchiveSession(ctx, "victim"); err != nil {
186 t.Fatal(err)
187 }
188 state, _ = first.Load(ctx)
189 if err := first.BeginPurge(ctx, "victim", state.Generation); err != nil {
190 t.Fatalf("new explicit purge: %v", err)
191 }
192 if err := second.ResumePurge(ctx, "victim", legacy); !errors.Is(err, ErrMutationConflict) {
193 t.Fatalf("old replay replaced new purge: %v", err)
194 }
195 state, _ = first.Load(ctx)
196 if ClassifyPurge(state, "victim") != PurgeTombstoned {
197 t.Fatalf("new purge changed by old replay: %+v", state.PendingOperations["purge-victim"])
198 }
199 }
200
201 func TestOnlyObservedReplayCanAdvanceLegacyPreparedPurge(t *testing.T) {
202 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
203 ctx := t.Context()
204 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
205 t.Fatal(err)
206 }
207 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, "victim", ""); err != nil {
208 t.Fatal(err)
209 }
210 if err := store.ArchiveSession(ctx, "victim"); err != nil {
211 t.Fatal(err)
212 }
213 var legacy Operation
214 if err := store.mutate(ctx, func(state *State) error {
215 status := state.SessionStates["victim"]
216 legacy = Operation{ID: "purge-victim", Kind: "purge", Phase: "prepared", Lifecycle: Deleted, SessionIDs: []string{"victim"}, ExpectedGeneration: status.Generation}
217 state.PendingOperations[legacy.ID] = legacy
218 return nil
219 }); err != nil {
220 t.Fatal(err)
221 }
222 state, err := store.Load(ctx)
223 if err != nil {
224 t.Fatal(err)
225 }
226 if err := store.BeginPurge(ctx, "victim", state.Generation); !errors.Is(err, ErrMutationConflict) {
227 t.Fatalf("new request adopted legacy prepare: %v", err)
228 }
229 if err := store.ResumePurge(ctx, "victim", legacy); err != nil {
230 t.Fatalf("observed replay did not advance prepare: %v", err)
231 }
232 state, err = store.Load(ctx)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if ClassifyPurge(state, "victim") != PurgeTombstoned || state.SessionStates["victim"].Lifecycle != Deleted {
237 t.Fatalf("legacy replay was not atomically tombstoned: %+v", state)
238 }
239 }
240
241 func TestInvalidPreparedDeletedPurgePreservesEvidence(t *testing.T) {
242 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
243 ctx := t.Context()
244 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
245 t.Fatal(err)
246 }
247 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, "victim", ""); err != nil {
248 t.Fatal(err)
249 }
250 if err := store.ArchiveSession(ctx, "victim"); err != nil {
251 t.Fatal(err)
252 }
253 var malformed Operation
254 if err := store.mutate(ctx, func(state *State) error {
255 status := state.SessionStates["victim"]
256 status.Lifecycle = Deleted
257 state.SessionStates["victim"] = status
258 malformed = Operation{ID: "purge-victim", Kind: "purge", Phase: "prepared", Lifecycle: Deleted, SessionIDs: []string{"victim"}, ExpectedGeneration: status.Generation}
259 state.PendingOperations[malformed.ID] = malformed
260 return nil
261 }); err != nil {
262 t.Fatal(err)
263 }
264 state, err := store.Load(ctx)
265 if err != nil {
266 t.Fatal(err)
267 }
268 if ClassifyPurge(state, "victim") != PurgeInvalid {
269 t.Fatalf("malformed purge classification = %v", ClassifyPurge(state, "victim"))
270 }
271 if err := store.ResumePurge(ctx, "victim", malformed); !errors.Is(err, ErrMutationConflict) {
272 t.Fatalf("malformed replay = %v, want conflict", err)
273 }
274 state, err = store.Load(ctx)
275 if err != nil {
276 t.Fatal(err)
277 }
278 if _, exists := state.PendingOperations[malformed.ID]; !exists {
279 t.Fatal("malformed purge evidence was removed")
280 }
281 }
282
283 func TestPurgeIgnoresUnrelatedSessionGeneration(t *testing.T) {
284 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
285 ctx := t.Context()
286 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
287 t.Fatal(err)
288 }
289 for _, id := range []string{"victim", "other"} {
290 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, id, ""); err != nil {
291 t.Fatal(err)
292 }
293 }
294 if err := store.ArchiveSession(ctx, "victim"); err != nil {
295 t.Fatal(err)
296 }
297 observed, _ := store.Load(ctx)
298 if err := store.ArchiveSession(ctx, "other"); err != nil {
299 t.Fatal(err)
300 }
301 if err := store.BeginPurge(ctx, "victim", observed.Generation); err != nil {
302 t.Fatalf("unrelated lifecycle change blocked purge: %v", err)
303 }
304 }
305
306 func TestRestoreOperationCommitClearsLegacyPreparedPurge(t *testing.T) {
307 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
308 ctx := t.Context()
309 if err := store.EnsureWorkspace(ctx, Workspace{ID: GlobalWorkspaceID, Visible: true}); err != nil {
310 t.Fatal(err)
311 }
312 if err := store.AttachSession(ctx, "", GlobalWorkspaceID, "victim", ""); err != nil {
313 t.Fatal(err)
314 }
315 if err := store.ArchiveSession(ctx, "victim"); err != nil {
316 t.Fatal(err)
317 }
318 if err := store.mutate(ctx, func(state *State) error {
319 status := state.SessionStates["victim"]
320 state.PendingOperations["purge-victim"] = Operation{ID: "purge-victim", Kind: "purge", Phase: "prepared", Lifecycle: Deleted, SessionIDs: []string{"victim"}, ExpectedGeneration: status.Generation}
321 return nil
322 }); err != nil {
323 t.Fatal(err)
324 }
325 state, _ := store.Load(ctx)
326 op := Operation{ID: "restore_victim", Kind: "restore", Lifecycle: Active, WorkspaceID: GlobalWorkspaceID, SessionIDs: []string{"victim"}, ExpectedGeneration: state.Generation}
327 if err := store.BeginOperation(ctx, op); err != nil {
328 t.Fatal(err)
329 }
330 if err := store.PrepareOperationContent(ctx, op.ID, op.SessionIDs, nil, nil); err != nil {
331 t.Fatal(err)
332 }
333 if err := store.CommitOperation(ctx, op.ID); err != nil {
334 t.Fatal(err)
335 }
336 state, _ = store.Load(ctx)
337 if state.SessionStates["victim"].Lifecycle != Active || ClassifyPurge(state, "victim") != PurgeAbsent {
338 t.Fatalf("restore commit left prepared purge: %+v", state)
339 }
340 }
341
342 func TestV2UpgradePreservesV1EvidenceAndUnknownFields(t *testing.T) {
343 path := filepath.Join(t.TempDir(), "workspace-state-v1.json")
344 root := t.TempDir()
345 rootJSON, err := json.Marshal(root)
346 if err != nil {
347 t.Fatal(err)
348 }
349 original := []byte(`{"version":1,"generation":7,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","root":"/global","title":"Mine","visible":false,"sessionIds":["old"],"future":{"nested":42}}},"archivedSessionIds":["old"],"pendingCreates":{"reserved":{"operationId":"op","workspaceId":"global","sessionId":"reserved","future":true}},"futureRoot":{"keep":true}}`)
350 original = []byte(strings.ReplaceAll(string(original), `"/global"`, string(rootJSON)))
351 if err := os.WriteFile(path, original, 0600); err != nil {
352 t.Fatal(err)
353 }
354 store := NewStore(path)
355 state, err := store.Load(t.Context())
356 if err != nil {
357 t.Fatal(err)
358 }
359 if state.Version != SchemaVersion || state.SessionStates["old"].Lifecycle != Archived {
360 t.Fatalf("upgrade: %+v", state)
361 }
362 unchanged, _ := os.ReadFile(path)
363 if string(unchanged) != string(original) {
364 t.Fatal("read-only load changed source")
365 }
366 if err := store.RestoreSession(t.Context(), "old"); err != nil {
367 t.Fatal(err)
368 }
369 backups, err := filepath.Glob(filepath.Join(filepath.Dir(path), "upgrade-backups", "workspace-v1-*.json"))
370 if err != nil || len(backups) != 1 {
371 t.Fatalf("backups: %v %v", backups, err)
372 }
373 saved, _ := os.ReadFile(backups[0])
374 if string(saved) != string(original) {
375 t.Fatal("backup differs from source")
376 }
377 body, _ := os.ReadFile(path)
378 for _, field := range []string{`"futureRoot"`, `"nested":42`, `"future":true`} {
379 if !strings.Contains(string(body), field) {
380 t.Fatalf("lost unknown field %s: %s", field, body)
381 }
382 }
383 state, err = store.Load(t.Context())
384 if err != nil {
385 t.Fatal(err)
386 }
387 if len(state.ArchivedSessionIDs) != 0 || !state.Workspaces["global"].Visible {
388 t.Fatal("restore did not publish visible membership")
389 }
390 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "global", Root: root, Title: "Overwrite", Visible: false}); err != nil {
391 t.Fatal(err)
392 }
393 state, _ = store.Load(t.Context())
394 if state.Workspaces["global"].Title != "Mine" || !state.Workspaces["global"].Visible {
395 t.Fatal("repeated discovery reset user presentation")
396 }
397 }
398
399 func TestV2BackupFailureLeavesV1Untouched(t *testing.T) {
400 root := t.TempDir()
401 path := filepath.Join(root, "state.json")
402 original := []byte(`{"version":1,"workspaceIds":[],"workspaces":{}}`)
403 if err := os.WriteFile(path, original, 0600); err != nil {
404 t.Fatal(err)
405 }
406 if err := os.WriteFile(filepath.Join(root, "upgrade-backups"), []byte("blocked"), 0600); err != nil {
407 t.Fatal(err)
408 }
409 if err := NewStore(path).EnsureWorkspace(t.Context(), Workspace{ID: "global"}); err == nil {
410 t.Fatal("upgrade ignored backup failure")
411 }
412 body, _ := os.ReadFile(path)
413 if string(body) != string(original) {
414 t.Fatal("failed upgrade changed source")
415 }
416 }
417
418 func TestV2RecoveryCommitIsAtomicAndIdempotent(t *testing.T) {
419 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
420 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "global", Root: t.TempDir()}); err != nil {
421 t.Fatal(err)
422 }
423 entry := RecoveryEntry{ID: "entry", SourceKey: "source", Status: "pending", Format: "legacy-trash"}
424 if err := store.RecordRecovery(t.Context(), entry); err != nil {
425 t.Fatal(err)
426 }
427 state, _ := store.Load(t.Context())
428 op := Operation{ID: "restore", Kind: "restore", RecoveryEntryID: entry.ID, WorkspaceID: "global", Lifecycle: Active, ExpectedGeneration: state.Generation}
429 if err := store.BeginOperation(t.Context(), op); err != nil {
430 t.Fatal(err)
431 }
432 mapping := SourceMapping{SourceKey: "source", Path: "/old", Format: "legacy", Fingerprint: "bytes", SessionID: "restored", WorkspaceID: "global"}
433 if err := store.PrepareOperationContent(t.Context(), op.ID, []string{"restored"}, &mapping, nil); err != nil {
434 t.Fatal(err)
435 }
436 state, _ = store.Load(t.Context())
437 if len(state.Workspaces["global"].SessionIDs) != 0 || state.RecoveryEntries["entry"].Status != "pending" {
438 t.Fatal("prepared operation leaked visible session")
439 }
440 restarted := NewStore(store.Path())
441 for range 2 {
442 if err := restarted.CommitOperation(t.Context(), op.ID); err != nil {
443 t.Fatal(err)
444 }
445 }
446 state, _ = restarted.Load(t.Context())
447 if len(state.Workspaces["global"].SessionIDs) != 1 || state.RecoveryEntries["entry"].Status != "restored" || state.SourceMappings["source"].SessionID != "restored" {
448 t.Fatalf("incomplete commit: %+v", state)
449 }
450 }
451
452 func TestV2ArchiveBatchFailureDoesNotChangeOtherSessions(t *testing.T) {
453 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
454 if err := store.EnsureWorkspace(t.Context(), Workspace{ID: "global"}); err != nil {
455 t.Fatal(err)
456 }
457 if err := store.AttachSession(t.Context(), "", "global", "a", ""); err != nil {
458 t.Fatal(err)
459 }
460 if err := store.SetLifecycle(t.Context(), []string{"a", "missing"}, Archived); !errors.Is(err, ErrSessionNotFound) {
461 t.Fatalf("error=%v", err)
462 }
463 state, _ := store.Load(t.Context())
464 if state.SessionStates["a"].Lifecycle != Active {
465 t.Fatal("partial archive committed")
466 }
467 }
468
469 func TestV2RejectsUnknownLifecycleWithoutRewriting(t *testing.T) {
470 path := filepath.Join(t.TempDir(), "state.json")
471 body := []byte(`{"version":2,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","sessionIds":["a"]}},"sessionStates":{"a":{"lifecycle":"future"}}}`)
472 if err := os.WriteFile(path, body, 0600); err != nil {
473 t.Fatal(err)
474 }
475 if _, err := NewStore(path).Load(t.Context()); !errors.Is(err, ErrUnsupportedVersion) {
476 t.Fatalf("error=%v", err)
477 }
478 after, _ := os.ReadFile(path)
479 if string(after) != string(body) {
480 t.Fatal("unknown lifecycle rewritten")
481 }
482 }
483
484 func TestV2NestedMetadataRoundTrip(t *testing.T) {
485 var state State
486 body := []byte(`{"version":2,"workspaceIds":[],"workspaces":{},"sessionStates":{"a":{"lifecycle":"archived","extension":{"a":1}}},"sourceMappings":{"s":{"sourceKey":"s","sessionId":"a","fingerprint":"f","extraField":true}},"pendingOperations":{"o":{"id":"o","phase":"prepared","lifecycle":"active","mapping":{"sourceKey":"s","sessionId":"a","fingerprint":"f","unknown":7},"extraOp":8}},"recoveryEntries":{"r":{"id":"r","sourceKey":"s","status":"pending","future":9}},"presentation":{"a":{"title":"keep","future":10}}}`)
487 if err := json.Unmarshal(body, &state); err != nil {
488 t.Fatal(err)
489 }
490 result, err := json.Marshal(state)
491 if err != nil {
492 t.Fatal(err)
493 }
494 for _, want := range []string{`"extension":{"a":1}`, `"extraField":true`, `"unknown":7`, `"extraOp":8`, `"future":9`, `"future":10`} {
495 if !strings.Contains(string(result), want) {
496 t.Fatalf("missing %s", want)
497 }
498 }
499 }
500
501 func TestArchiveImportsPublishOnlyWithWholeBatch(t *testing.T) {
502 store := NewStore(filepath.Join(t.TempDir(), "state.json"))
503 ctx := t.Context()
504 if err := store.EnsureWorkspace(ctx, Workspace{ID: "global"}); err != nil {
505 t.Fatal(err)
506 }
507 child := Operation{ID: "import", Kind: "archive-import", WorkspaceID: "global", SessionIDs: []string{"a"}, Lifecycle: Archived}
508 if err := store.BeginOperation(ctx, child); err != nil {
509 t.Fatal(err)
510 }
511 if err := store.PrepareOperationContent(ctx, child.ID, child.SessionIDs, nil, nil); err != nil {
512 t.Fatal(err)
513 }
514 before, _ := store.Load(ctx)
515 if len(before.Workspaces["global"].SessionIDs) != 0 {
516 t.Fatal("staged import leaked")
517 }
518 op := Operation{ID: "archive", Kind: "archive", Lifecycle: Archived, SessionIDs: []string{"a", "missing"}, Dependencies: []string{child.ID}}
519 if err := store.BeginOperation(ctx, op); err != nil {
520 t.Fatal(err)
521 }
522 if err := store.PrepareOperationContent(ctx, op.ID, op.SessionIDs, nil, nil); err != nil {
523 t.Fatal(err)
524 }
525 if err := store.CommitOperation(ctx, op.ID); err == nil {
526 t.Fatal("invalid member committed")
527 }
528 failed, _ := store.Load(ctx)
529 if len(failed.Workspaces["global"].SessionIDs) != 0 || failed.PendingOperations[child.ID].Phase != "content_ready" {
530 t.Fatal("partial commit")
531 }
532 op.ID, op.SessionIDs = "valid", []string{"a"}
533 if err := store.BeginOperation(ctx, op); err != nil {
534 t.Fatal(err)
535 }
536 if err := store.PrepareOperationContent(ctx, op.ID, op.SessionIDs, nil, nil); err != nil {
537 t.Fatal(err)
538 }
539 if err := store.CommitOperation(ctx, op.ID); err != nil {
540 t.Fatal(err)
541 }
542 committed, _ := store.Load(ctx)
543 if committed.SessionStates["a"].Lifecycle != Archived {
544 t.Fatal("archive not published")
545 }
546 if err := store.CommitOperation(ctx, op.ID); err != nil {
547 t.Fatal(err)
548 }
549 retried, _ := store.Load(ctx)
550 if retried.Generation != committed.Generation {
551 t.Fatal("idempotent retry invalidated cursors")
552 }
553 if err := store.PrepareOperationContent(ctx, op.ID, []string{"different"}, nil, nil); !errors.Is(err, ErrMutationConflict) {
554 t.Fatalf("retarget=%v", err)
555 }
556 }
557
558 func TestV2MissingLifecycleIsNotReinterpretedAsActive(t *testing.T) {
559 path := filepath.Join(t.TempDir(), "state.json")
560 if err := os.WriteFile(path, []byte(`{"version":2,"workspaceIds":["global"],"workspaces":{"global":{"id":"global","sessionIds":["a"]}}}`), 0600); err != nil {
561 t.Fatal(err)
562 }
563 if _, err := NewStore(path).Load(t.Context()); !errors.Is(err, ErrUnsupportedVersion) {
564 t.Fatalf("missing state=%v", err)
565 }
566 }
567
567 lines GO