| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/desktop/internal/workspacestate" |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | // An old build may adopt only the JSONL receipt, leaving its converted store |
| 16 | // unregistered. A later import must not bypass the destination tombstone. |
| 17 | func TestUnmappedHistoricalConversionDoesNotRevivePurgedLegacyTarget(t *testing.T) { |
| 18 | for _, continued := range []bool{false, true} { |
| 19 | name := "same-content" |
| 20 | if continued { |
| 21 | name = "continued-conversion" |
| 22 | } |
| 23 | t.Run(name, func(t *testing.T) { checkUnmappedPurgedConversion(t, continued) }) |
| 24 | } |
| 25 | t.Run("divergent-tool-history", func(t *testing.T) { |
| 26 | checkUnmappedPurgedConversion(t, true, func(old *session.Service, ref session.SessionRef) { |
| 27 | binding, err := old.Open(t.Context(), ref) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | messages := []provider.Message{ |
| 32 | {Role: provider.RoleUser, Content: "independent branch", RawContent: "complete original user input"}, |
| 33 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call", Name: "fixture_tool", Arguments: "{}"}}}, |
| 34 | {Role: provider.RoleTool, ToolCallID: "call", Content: "complete tool result"}, |
| 35 | } |
| 36 | payload, err := json.Marshal(map[string]any{"messages": messages}) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | if _, err := binding.Runtime().Session().AppendBatch(t.Context(), "divergence", []session.Event{{Kind: "history/replace", Payload: payload}}); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := binding.Release(t.Context()); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if err := old.Close(t.Context(), ref); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | }) |
| 50 | }) |
| 51 | t.Run("omitted-conversion-head", func(t *testing.T) { |
| 52 | checkUnmappedPurgedConversion(t, false, func(old *session.Service, ref session.SessionRef) { |
| 53 | path := filepath.Join(config.SessionStoreDir(), ref.SessionID, "manifest.json") |
| 54 | body, err := os.ReadFile(path) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | var manifest session.Manifest |
| 59 | if err := json.Unmarshal(body, &manifest); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | manifest.Source.LegacyHeadID = "" |
| 63 | body, err = json.Marshal(manifest) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | if err := os.WriteFile(path, body, 0600); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | }) |
| 71 | }) |
| 72 | } |
| 73 | |
| 74 | func checkUnmappedPurgedConversion(t *testing.T, continued bool, changes ...func(*session.Service, session.SessionRef)) { |
| 75 | t.Helper() |
| 76 | isolateDesktopUserDirs(t) |
| 77 | path, _, head := migrationSingleDAGFixture(t) |
| 78 | root := config.SessionStoreDir() |
| 79 | converted, err := session.MigrateLegacyHead(t.Context(), path, root, head) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if continued || len(changes) != 0 { |
| 84 | old, err := session.NewService("fixture", session.NewFilesystemPersistence(root)) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | ref := session.SessionRef{HostID: "fixture", SessionID: converted.TargetID} |
| 89 | if len(changes) != 0 { |
| 90 | changes[0](old, ref) |
| 91 | } else { |
| 92 | appendMigrationTestMessage(t, old, ref, "independent later work") |
| 93 | } |
| 94 | if err := old.Shutdown(t.Context()); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | } |
| 98 | app := newHistoricalLifecycleApp(t) |
| 99 | workspace, err := app.ensureDesktopWorkspace(t.Context(), "global", "") |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | if err := app.migrateLegacySession(t.Context(), path, desktopMigrationSource{scope: "global", headID: head}, workspace); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | mapping := state.SourceMappings[desktopSourceKey(path, head)] |
| 111 | if mapping.SessionID != converted.TargetID { |
| 112 | t.Fatalf("fixture must share the migrated identity: %s != %s", mapping.SessionID, converted.TargetID) |
| 113 | } |
| 114 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID} |
| 115 | if err := app.ArchiveCanonicalSession(ref); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if err := app.PurgeCanonicalSession(ref); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | app.stopHistoricalImports() |
| 122 | app.closeSessionServices() |
| 123 | app = newHistoricalLifecycleApp(t) |
| 124 | before := migrationSourceSnapshot(t, canonicalMigrationSourceFiles(root, converted.TargetID)) |
| 125 | key := historicalLifecycleID(t, app, converted.TargetID) |
| 126 | selector := SessionSelector{Source: &SessionSourceRef{ |
| 127 | Path: filepath.Join(root, converted.TargetID), SourceKey: key, |
| 128 | }} |
| 129 | result, err := app.ArchiveSessionTarget(selector) |
| 130 | if err != nil || !result.Committed { |
| 131 | t.Fatalf("archive: %+v, %v", result, err) |
| 132 | } |
| 133 | state, loadErr := app.workspaceRegistry().Load(t.Context()) |
| 134 | if loadErr != nil || state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Deleted { |
| 135 | t.Fatalf("purged adoption changed: %v", loadErr) |
| 136 | } |
| 137 | canonicalMapping, exists, err := state.ResolveSource(key) |
| 138 | if err != nil || !exists { |
| 139 | t.Fatalf("missing canonical receipt: %v", err) |
| 140 | } |
| 141 | if continued { |
| 142 | if canonicalMapping.SessionID == ref.SessionID || state.SessionStates[canonicalMapping.SessionID].Lifecycle != workspacestate.Archived || result.Outcome != "archived_copy" { |
| 143 | t.Fatalf("later work was not independently archived: %+v, %+v", canonicalMapping, result) |
| 144 | } |
| 145 | digest, err := canonicalMigrationDigest(t.Context(), app.desktopSessionService("").Query(), session.SessionRef{HostID: localDesktopHostID, SessionID: canonicalMapping.SessionID}) |
| 146 | old, openErr := session.NewService("source", session.NewFilesystemPersistence(root)) |
| 147 | if openErr != nil { |
| 148 | t.Fatal(openErr) |
| 149 | } |
| 150 | expected, readErr := canonicalMigrationDigest(t.Context(), old.Query(), session.SessionRef{HostID: "source", SessionID: converted.TargetID}) |
| 151 | _ = old.Shutdown(t.Context()) |
| 152 | if err != nil || readErr != nil || digest != expected { |
| 153 | t.Fatalf("full history changed: %v, %v", err, readErr) |
| 154 | } |
| 155 | } else if canonicalMapping.SessionID != ref.SessionID || result.Outcome != "already_removed" { |
| 156 | t.Fatalf("identical retired copy resurrected: %+v, %+v", canonicalMapping, result) |
| 157 | } |
| 158 | beforeStates := len(state.SessionStates) |
| 159 | app.stopHistoricalImports() |
| 160 | app.closeSessionServices() |
| 161 | app = newHistoricalLifecycleApp(t) |
| 162 | repeated, err := app.ArchiveSessionTarget(selector) |
| 163 | if err != nil || !repeated.Committed || repeated.TargetKey != result.TargetKey { |
| 164 | t.Fatalf("restart retry: %+v, %v", repeated, err) |
| 165 | } |
| 166 | state, err = app.workspaceRegistry().Load(t.Context()) |
| 167 | if err != nil || len(state.SessionStates) != beforeStates { |
| 168 | t.Fatalf("duplicate target after retry: %v", err) |
| 169 | } |
| 170 | if continued { |
| 171 | copyRef := session.SessionRef{HostID: localDesktopHostID, SessionID: canonicalMapping.SessionID} |
| 172 | if err := app.RestoreCanonicalSession(copyRef); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if err := app.ArchiveCanonicalSession(copyRef); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | } |
| 179 | ledger, loadErr := readDesktopMigrationLedger() |
| 180 | if loadErr != nil { |
| 181 | t.Fatal(loadErr) |
| 182 | } |
| 183 | record := ledger.Records[desktopCanonicalMigrationKey(root, converted.TargetID)] |
| 184 | adopted := ledger.Records[desktopLegacyHeadKey(path, head)] |
| 185 | if record.ContentDigest == "" || (record.ContentDigest != adopted.ContentDigest) != continued { |
| 186 | t.Fatal("fixture did not preserve the distinction between an identical copy and later work") |
| 187 | } |
| 188 | t.Logf("conversion ledger after archive: status=%s target=%s attempts=%d", record.Status, record.TargetSessionID, record.Attempts) |
| 189 | assertMigrationSourceSnapshot(t, before) |
| 190 | } |
| 191 |