返回 DeepSeek-Reasonix
fixture.go
1 // Package upgradefixture creates and verifies disposable legacy data for native
2 // packaged-app acceptance and the Desktop migration regression.
3 package upgradefixture
4
5 import (
6 "context"
7 "crypto/sha256"
8 "database/sql"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "net/url"
14 "os"
15 "path/filepath"
16 "strings"
17 "time"
18
19 "reasonix/desktop/internal/workspacestate"
20 "reasonix/internal/agent"
21 "reasonix/internal/config"
22 "reasonix/internal/session"
23 "reasonix/internal/sqliteuri"
24 "reasonix/internal/topicstate"
25 )
26
27 const (
28 fixtureSessionID = "windows-upgrade-fixture"
29 fixtureTopicID = "windows-upgrade-topic"
30 fixtureTitle = "Upgrade fixture topic"
31 fixtureQuestion = "Please restore my earlier conversation."
32 fixtureText = "Restored assistant body: upgrade-history-7c82 中文 %20 #"
33 )
34
35 type fixtureReport struct {
36 Home string `json:"home"`
37 RegistryPath string `json:"registryPath"`
38 RegistrySHA256 string `json:"registrySha256"`
39 SessionID string `json:"sessionId"`
40 TopicID string `json:"topicId"`
41 VisibleText string `json:"visibleText"`
42 ProjectRoot string `json:"projectRoot"`
43 LegacyPath string `json:"legacyPath"`
44 LegacySHA256 string `json:"legacySha256"`
45 }
46
47 type legacyMessage struct {
48 Role string `json:"role"`
49 Content string `json:"content"`
50 }
51
52 // Run requires an isolated, disposable home. It never starts the application.
53 func Run(mode, home, reportPath, phase string) error {
54 if strings.TrimSpace(home) == "" || strings.TrimSpace(reportPath) == "" {
55 return errors.New("--home and --report are required")
56 }
57 absHome, err := filepath.Abs(home)
58 if err != nil {
59 return err
60 }
61 for key, value := range map[string]string{
62 "REASONIX_HOME": absHome, "REASONIX_STATE_HOME": absHome, "REASONIX_CACHE_HOME": filepath.Join(absHome, "cache"),
63 } {
64 previous, existed := os.LookupEnv(key)
65 if err := os.Setenv(key, value); err != nil {
66 return err
67 }
68 defer func() {
69 if existed {
70 _ = os.Setenv(key, previous)
71 } else {
72 _ = os.Unsetenv(key)
73 }
74 }()
75 }
76 switch mode {
77 case "create":
78 return createFixture(context.Background(), absHome, reportPath)
79 case "verify":
80 return verifyFixture(context.Background(), reportPath, phase)
81 default:
82 return errors.New("--mode must be create or verify")
83 }
84 }
85
86 func createFixture(ctx context.Context, home, reportPath string) error {
87 if _, err := os.Stat(home); err == nil {
88 entries, readErr := os.ReadDir(home)
89 if readErr != nil {
90 return readErr
91 }
92 if len(entries) != 0 {
93 return errors.New("fixture home must be empty")
94 }
95 } else if !os.IsNotExist(err) {
96 return err
97 }
98 if err := os.MkdirAll(home, 0o700); err != nil {
99 return err
100 }
101 // Model an existing installation without credentials or a live provider.
102 // Otherwise first-run onboarding opens settings instead of the restored tab.
103 cfg := config.Default()
104 cfg.DefaultModel = "upgrade-fixture/offline"
105 cfg.Desktop.ProviderAccess = []string{"upgrade-fixture"}
106 cfg.Providers = []config.ProviderEntry{{
107 Name: "upgrade-fixture", Kind: "openai", BaseURL: "http://127.0.0.1:1/v1", Model: "offline",
108 }}
109 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
110 return err
111 }
112 legacyPath := filepath.Join(config.SessionDir(), fixtureSessionID+".jsonl")
113 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
114 return err
115 }
116 legacy, err := encodeLegacyHistory(
117 legacyMessage{Role: "user", Content: fixtureQuestion},
118 legacyMessage{Role: "assistant", Content: fixtureText},
119 )
120 if err != nil {
121 return err
122 }
123 if err := os.WriteFile(legacyPath, legacy, 0o600); err != nil {
124 return err
125 }
126 if err := agent.SaveBranchMetaPreserveUpdated(legacyPath, agent.BranchMeta{Scope: "global", TopicID: fixtureTopicID, TopicTitle: fixtureTitle}); err != nil {
127 return err
128 }
129
130 projectRoot := filepath.Join(home, "project # %20 中文")
131 if err := os.MkdirAll(projectRoot, 0o700); err != nil {
132 return err
133 }
134 projectsBody, err := json.Marshal(map[string]any{"projects": []map[string]any{{"root": projectRoot, "topics": []string{"project-topic"}}}})
135 if err != nil {
136 return err
137 }
138 if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-projects.json"), projectsBody, 0o600); err != nil {
139 return err
140 }
141 if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(""), fixtureTopicID, "global"); err != nil {
142 return err
143 }
144 if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(projectRoot), "project-topic", "project"); err != nil {
145 return err
146 }
147
148 registry, err := json.Marshal(map[string]any{
149 "version": 1,
150 "generation": 7,
151 "workspaceIds": []string{"global"},
152 "workspaces": map[string]any{
153 "global": map[string]any{
154 "id": "global",
155 "root": filepath.Join(config.ReasonixHomeDir(), "global-workspace"),
156 "title": "Global",
157 "visible": true,
158 "sessionIds": []string{},
159 "futureWorkspace": map[string]any{"preserve": 42},
160 },
161 },
162 "archivedSessionIds": []string{},
163 "pendingCreates": map[string]any{},
164 "futureRoot": map[string]any{"preserve": true},
165 })
166 if err != nil {
167 return err
168 }
169 registryPath := config.DesktopWorkspaceStatePath()
170 if err := os.MkdirAll(filepath.Dir(registryPath), 0o700); err != nil {
171 return err
172 }
173 if err := os.WriteFile(registryPath, registry, 0o600); err != nil {
174 return err
175 }
176 tabs := map[string]any{
177 "tabs": []map[string]any{{"id": "upgrade-tab", "scope": "global", "workspaceId": "global", "topicId": fixtureTopicID, "sessionPath": legacyPath}},
178 "activeTab": "upgrade-tab", "tabOrder": []string{"upgrade-tab"},
179 }
180 tabsBody, err := json.Marshal(tabs)
181 if err != nil {
182 return err
183 }
184 if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-tabs.json"), tabsBody, 0o600); err != nil {
185 return err
186 }
187 digest := sha256.Sum256(registry)
188 legacyDigest := sha256.Sum256(legacy)
189 report := fixtureReport{Home: home, RegistryPath: registryPath, RegistrySHA256: hex.EncodeToString(digest[:]), TopicID: fixtureTopicID, VisibleText: fixtureText, ProjectRoot: projectRoot, LegacyPath: legacyPath, LegacySHA256: hex.EncodeToString(legacyDigest[:])}
190 return writeJSON(reportPath, report)
191 }
192
193 func encodeLegacyHistory(messages ...legacyMessage) ([]byte, error) {
194 var body strings.Builder
195 encoder := json.NewEncoder(&body)
196 for _, message := range messages {
197 if err := encoder.Encode(message); err != nil {
198 return nil, err
199 }
200 }
201 return []byte(body.String()), nil
202 }
203
204 func createTopicDatabase(ctx context.Context, path, topicID, marker string) error {
205 store, err := topicstate.Open(ctx, path)
206 if err != nil {
207 return err
208 }
209 if _, err := store.Update(ctx, topicID, func(record *topicstate.Record) {
210 record.Title = fixtureTitle
211 record.TitleSource = "manual"
212 }); err != nil {
213 _ = store.Close()
214 return err
215 }
216 if err := store.Close(); err != nil {
217 return err
218 }
219 dsn, err := sqliteuri.Disk(path, url.Values{"_pragma": {"busy_timeout(5000)"}})
220 if err != nil {
221 return err
222 }
223 db, err := sql.Open("sqlite", dsn)
224 if err != nil {
225 return err
226 }
227 defer db.Close()
228 for _, statement := range []string{
229 `ALTER TABLE topics ADD COLUMN future_column TEXT NOT NULL DEFAULT ''`,
230 `UPDATE topics SET future_column='future-` + marker + `' WHERE topic_id='` + topicID + `'`,
231 `CREATE TABLE future_data(marker TEXT NOT NULL)`,
232 `INSERT INTO future_data(marker) VALUES('` + marker + `')`,
233 } {
234 if _, err := db.ExecContext(ctx, statement); err != nil {
235 return err
236 }
237 }
238 return nil
239 }
240
241 func verifyFixture(ctx context.Context, reportPath, phase string) error {
242 var report fixtureReport
243 if err := readJSON(reportPath, &report); err != nil {
244 return err
245 }
246 if err := verifyRegistryFields(report.RegistryPath); err != nil {
247 return err
248 }
249 return verifyMigratedSession(ctx, report, reportPath, phase)
250 }
251
252 func verifyRegistryFields(path string) error {
253 body, err := os.ReadFile(path)
254 if err != nil {
255 return err
256 }
257 var registry struct {
258 Version int `json:"version"`
259 FutureRoot struct {
260 Preserve bool `json:"preserve"`
261 } `json:"futureRoot"`
262 Workspaces map[string]struct {
263 FutureWorkspace struct {
264 Preserve int `json:"preserve"`
265 } `json:"futureWorkspace"`
266 } `json:"workspaces"`
267 }
268 if err := json.Unmarshal(body, &registry); err != nil {
269 return err
270 }
271 if registry.Version != 3 || !registry.FutureRoot.Preserve || registry.Workspaces["global"].FutureWorkspace.Preserve != 42 {
272 return errors.New("upgraded registry lost its version or unknown field values")
273 }
274 return nil
275 }
276
277 func verifyMigratedSession(ctx context.Context, report fixtureReport, reportPath, phase string) error {
278 state, err := workspacestate.NewStore(report.RegistryPath).Load(ctx)
279 if err != nil {
280 return err
281 }
282 legacy, err := os.ReadFile(report.LegacyPath)
283 if err != nil {
284 return err
285 }
286 legacyDigest := sha256.Sum256(legacy)
287 if hex.EncodeToString(legacyDigest[:]) != report.LegacySHA256 {
288 return errors.New("legacy JSONL source was modified")
289 }
290 mappings := 0
291 for _, mapping := range state.SourceMappings {
292 if agent.CanonicalSessionPath(mapping.Path) == agent.CanonicalSessionPath(report.LegacyPath) {
293 mappings++
294 report.SessionID = mapping.SessionID
295 }
296 }
297 if mappings != 1 || report.SessionID == "" {
298 return fmt.Errorf("legacy source mappings=%d, session=%q", mappings, report.SessionID)
299 }
300 presentation := state.Presentation[report.SessionID]
301 if presentation.TopicID != report.TopicID || presentation.Title != fixtureTitle {
302 return fmt.Errorf("migrated topic relation mismatch: %+v", presentation)
303 }
304 count := 0
305 for _, id := range state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs {
306 if id == report.SessionID {
307 count++
308 }
309 }
310 if count != 1 || len(state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs) != 1 || state.SessionStates[report.SessionID].Lifecycle != workspacestate.Active {
311 return fmt.Errorf("session membership count=%d lifecycle=%q", count, state.SessionStates[report.SessionID].Lifecycle)
312 }
313 service, err := session.NewService("desktop", session.NewFilesystemPersistence(config.DesktopSessionStoreDir()))
314 if err != nil {
315 return err
316 }
317 history, err := service.Query().History(ctx, session.SessionRef{HostID: "desktop", SessionID: report.SessionID})
318 _ = service.CloseAll(ctx)
319 if err != nil {
320 return err
321 }
322 if len(history) != 2 || history[0].Role != "user" || history[0].Content != fixtureQuestion || history[1].Role != "assistant" || history[1].Content != report.VisibleText {
323 return fmt.Errorf("history mismatch: %+v", history)
324 }
325 for path, marker := range map[string]string{config.DesktopTopicStatePath(""): "global", config.DesktopTopicStatePath(report.ProjectRoot): "project"} {
326 if err := verifyTopicDatabase(ctx, path, marker); err != nil {
327 return err
328 }
329 }
330 if err := verifyBackups(ctx, report); err != nil {
331 return err
332 }
333 resultPath := filepath.Join(filepath.Dir(reportPath), "verification-"+phase+".json")
334 if phase == "restart" {
335 var first struct {
336 SessionID string `json:"sessionId"`
337 }
338 if err := readJSON(filepath.Join(filepath.Dir(reportPath), "verification-first.json"), &first); err != nil {
339 return err
340 }
341 if first.SessionID != report.SessionID {
342 return errors.New("restart changed migrated session identity")
343 }
344 }
345 return writeJSON(resultPath, map[string]any{"phase": phase, "version": state.Version, "sessionId": report.SessionID, "sessionCount": count, "history": report.VisibleText, "legacySha256": report.LegacySHA256, "topicBackups": 2, "unknownData": "preserved", "verifiedAt": time.Now().UTC()})
346 }
347
348 func verifyBackups(ctx context.Context, report fixtureReport) error {
349 backupRoot := filepath.Join(config.ReasonixHomeDir(), "desktop", "upgrade-backups")
350 topicBackups, err := filepath.Glob(filepath.Join(backupRoot, "topics-*.sqlite"))
351 if err != nil {
352 return fmt.Errorf("list topic backups: %w", err)
353 }
354 if len(topicBackups) != 2 {
355 return fmt.Errorf("topic backups=%d, want 2", len(topicBackups))
356 }
357 markers := map[string]bool{}
358 for _, path := range topicBackups {
359 marker, err := topicMarker(ctx, path)
360 if err != nil {
361 return err
362 }
363 markers[marker] = true
364 }
365 if !markers["global"] || !markers["project"] {
366 return fmt.Errorf("topic backup markers=%v", markers)
367 }
368 // Startup may snapshot the newer registry too. Bind acceptance to the
369 // immutable original instead of assuming no later snapshot can exist.
370 backupPath := filepath.Join(backupRoot, "workspace-state-v1.json-"+report.RegistrySHA256+".bak")
371 backupBody, err := os.ReadFile(backupPath)
372 if err != nil {
373 return err
374 }
375 digest := sha256.Sum256(backupBody)
376 if hex.EncodeToString(digest[:]) != report.RegistrySHA256 {
377 return errors.New("registry backup does not match the v1 source")
378 }
379 return nil
380 }
381
382 func verifyTopicDatabase(ctx context.Context, path, wantMarker string) error {
383 marker, err := topicMarker(ctx, path)
384 if err != nil {
385 return err
386 }
387 if marker != wantMarker {
388 return fmt.Errorf("topic database %s marker=%q, want %q", path, marker, wantMarker)
389 }
390 return nil
391 }
392
393 func topicMarker(ctx context.Context, path string) (string, error) {
394 dsn, err := sqliteuri.Disk(path, url.Values{"mode": {"ro"}})
395 if err != nil {
396 return "", err
397 }
398 db, err := sql.Open("sqlite", dsn)
399 if err != nil {
400 return "", err
401 }
402 defer db.Close()
403 var marker, future string
404 if err := db.QueryRowContext(ctx, `SELECT marker FROM future_data`).Scan(&marker); err != nil {
405 return "", err
406 }
407 if err := db.QueryRowContext(ctx, `SELECT future_column FROM topics LIMIT 1`).Scan(&future); err != nil {
408 return "", err
409 }
410 if future != "future-"+marker {
411 return "", fmt.Errorf("future topic column=%q for marker %q", future, marker)
412 }
413 return marker, nil
414 }
415
416 func writeJSON(path string, value any) error {
417 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
418 return err
419 }
420 body, err := json.MarshalIndent(value, "", " ")
421 if err != nil {
422 return err
423 }
424 return os.WriteFile(path, append(body, '\n'), 0o600)
425 }
426
427 func readJSON(path string, value any) error {
428 body, err := os.ReadFile(path)
429 if err != nil {
430 return err
431 }
432 return json.Unmarshal(body, value)
433 }
434
434 lines GO