返回 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/provider"
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 TopicID string `json:"topicId"`
40 VisibleText string `json:"visibleText"`
41 ProjectRoot string `json:"projectRoot"`
42 LegacyPath string `json:"legacyPath"`
43 }
44
45 type legacyMessage struct {
46 Role string `json:"role"`
47 Content string `json:"content"`
48 }
49
50 // Run requires an isolated, disposable home. It never starts the application.
51 func Run(mode, home, reportPath, phase string) error {
52 if strings.TrimSpace(home) == "" || strings.TrimSpace(reportPath) == "" {
53 return errors.New("--home and --report are required")
54 }
55 absHome, err := filepath.Abs(home)
56 if err != nil {
57 return err
58 }
59 for key, value := range map[string]string{
60 "REASONIX_HOME": absHome, "REASONIX_STATE_HOME": absHome, "REASONIX_CACHE_HOME": filepath.Join(absHome, "cache"),
61 } {
62 previous, existed := os.LookupEnv(key)
63 if err := os.Setenv(key, value); err != nil {
64 return err
65 }
66 defer func() {
67 if existed {
68 _ = os.Setenv(key, previous)
69 } else {
70 _ = os.Unsetenv(key)
71 }
72 }()
73 }
74 switch mode {
75 case "create":
76 return createFixture(context.Background(), absHome, reportPath)
77 case "verify":
78 return verifyFixture(context.Background(), reportPath, phase)
79 default:
80 return errors.New("--mode must be create or verify")
81 }
82 }
83
84 func createFixture(ctx context.Context, home, reportPath string) error {
85 if _, err := os.Stat(home); err == nil {
86 entries, readErr := os.ReadDir(home)
87 if readErr != nil {
88 return readErr
89 }
90 if len(entries) != 0 {
91 return errors.New("fixture home must be empty")
92 }
93 } else if !os.IsNotExist(err) {
94 return err
95 }
96 if err := os.MkdirAll(home, 0o700); err != nil {
97 return err
98 }
99 // Model an existing installation without credentials or a live provider.
100 // Otherwise first-run onboarding opens settings instead of the restored tab.
101 cfg := config.Default()
102 cfg.DefaultModel = "upgrade-fixture/offline"
103 cfg.Desktop.ProviderAccess = []string{"upgrade-fixture"}
104 cfg.Providers = []config.ProviderEntry{{
105 Name: "upgrade-fixture", Kind: "openai", BaseURL: "http://127.0.0.1:1/v1", Model: "offline",
106 }}
107 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
108 return err
109 }
110 legacyPath := filepath.Join(config.SessionDir(), fixtureSessionID+".jsonl")
111 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil {
112 return err
113 }
114 legacy, err := encodeLegacyHistory(
115 legacyMessage{Role: "user", Content: fixtureQuestion},
116 legacyMessage{Role: "assistant", Content: fixtureText},
117 )
118 if err != nil {
119 return err
120 }
121 if err := os.WriteFile(legacyPath, legacy, 0o600); err != nil {
122 return err
123 }
124 if err := agent.SaveBranchMetaPreserveUpdated(legacyPath, agent.BranchMeta{Scope: "global", TopicID: fixtureTopicID, TopicTitle: fixtureTitle}); err != nil {
125 return err
126 }
127
128 projectRoot := filepath.Join(home, "project # %20 中文")
129 if err := os.MkdirAll(projectRoot, 0o700); err != nil {
130 return err
131 }
132 projectsBody, err := json.Marshal(map[string]any{"projects": []map[string]any{{"root": projectRoot, "topics": []string{"project-topic"}}}})
133 if err != nil {
134 return err
135 }
136 if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-projects.json"), projectsBody, 0o600); err != nil {
137 return err
138 }
139 if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(""), fixtureTopicID, "global"); err != nil {
140 return err
141 }
142 if err := createTopicDatabase(ctx, config.DesktopTopicStatePath(projectRoot), "project-topic", "project"); err != nil {
143 return err
144 }
145
146 registry, err := json.Marshal(map[string]any{
147 "version": 1,
148 "generation": 7,
149 "workspaceIds": []string{"global"},
150 "workspaces": map[string]any{
151 "global": map[string]any{
152 "id": "global",
153 "root": filepath.Join(config.ReasonixHomeDir(), "global-workspace"),
154 "title": "Global",
155 "visible": true,
156 "sessionIds": []string{},
157 "futureWorkspace": map[string]any{"preserve": 42},
158 },
159 },
160 "archivedSessionIds": []string{},
161 "pendingCreates": map[string]any{},
162 "futureRoot": map[string]any{"preserve": true},
163 })
164 if err != nil {
165 return err
166 }
167 registryPath := config.DesktopWorkspaceStatePath()
168 if err := os.MkdirAll(filepath.Dir(registryPath), 0o700); err != nil {
169 return err
170 }
171 if err := os.WriteFile(registryPath, registry, 0o600); err != nil {
172 return err
173 }
174 tabs := map[string]any{
175 "tabs": []map[string]any{{"id": "upgrade-tab", "scope": "global", "workspaceId": "global", "topicId": fixtureTopicID, "sessionPath": legacyPath}},
176 "activeTab": "upgrade-tab", "tabOrder": []string{"upgrade-tab"},
177 }
178 tabsBody, err := json.Marshal(tabs)
179 if err != nil {
180 return err
181 }
182 if err := os.WriteFile(filepath.Join(config.ReasonixHomeDir(), "desktop-tabs.json"), tabsBody, 0o600); err != nil {
183 return err
184 }
185 digest := sha256.Sum256(registry)
186 report := fixtureReport{Home: home, RegistryPath: registryPath, RegistrySHA256: hex.EncodeToString(digest[:]), TopicID: fixtureTopicID, VisibleText: fixtureText, ProjectRoot: projectRoot, LegacyPath: legacyPath}
187 return writeJSON(reportPath, report)
188 }
189
190 func encodeLegacyHistory(messages ...legacyMessage) ([]byte, error) {
191 var body strings.Builder
192 encoder := json.NewEncoder(&body)
193 for _, message := range messages {
194 if err := encoder.Encode(message); err != nil {
195 return nil, err
196 }
197 }
198 return []byte(body.String()), nil
199 }
200
201 func createTopicDatabase(ctx context.Context, path, topicID, marker string) error {
202 store, err := topicstate.Open(ctx, path)
203 if err != nil {
204 return err
205 }
206 if _, err := store.Update(ctx, topicID, func(record *topicstate.Record) {
207 record.Title = fixtureTitle
208 record.TitleSource = "manual"
209 }); err != nil {
210 _ = store.Close()
211 return err
212 }
213 if err := store.Close(); err != nil {
214 return err
215 }
216 dsn, err := sqliteuri.Disk(path, url.Values{"_pragma": {"busy_timeout(5000)"}})
217 if err != nil {
218 return err
219 }
220 db, err := sql.Open("sqlite", dsn)
221 if err != nil {
222 return err
223 }
224 defer db.Close()
225 for _, statement := range []string{
226 `ALTER TABLE topics ADD COLUMN future_column TEXT NOT NULL DEFAULT ''`,
227 `UPDATE topics SET future_column='future-` + marker + `' WHERE topic_id='` + topicID + `'`,
228 `CREATE TABLE future_data(marker TEXT NOT NULL)`,
229 `INSERT INTO future_data(marker) VALUES('` + marker + `')`,
230 } {
231 if _, err := db.ExecContext(ctx, statement); err != nil {
232 return err
233 }
234 }
235 return nil
236 }
237
238 func verifyFixture(ctx context.Context, reportPath, phase string) error {
239 var report fixtureReport
240 if err := readJSON(reportPath, &report); err != nil {
241 return err
242 }
243 if err := verifyRegistryFields(report.RegistryPath); err != nil {
244 return err
245 }
246 return verifyLegacySessionContinuity(ctx, report, reportPath, phase)
247 }
248
249 func verifyRegistryFields(path string) error {
250 body, err := os.ReadFile(path)
251 if err != nil {
252 return err
253 }
254 var registry struct {
255 Version int `json:"version"`
256 FutureRoot struct {
257 Preserve bool `json:"preserve"`
258 } `json:"futureRoot"`
259 Workspaces map[string]struct {
260 FutureWorkspace struct {
261 Preserve int `json:"preserve"`
262 } `json:"futureWorkspace"`
263 } `json:"workspaces"`
264 }
265 if err := json.Unmarshal(body, &registry); err != nil {
266 return err
267 }
268 if registry.Version != 3 || !registry.FutureRoot.Preserve || registry.Workspaces["global"].FutureWorkspace.Preserve != 42 {
269 return errors.New("upgraded registry lost its version or unknown field values")
270 }
271 return nil
272 }
273
274 func verifyLegacySessionContinuity(ctx context.Context, report fixtureReport, reportPath, phase string) error {
275 state, err := workspacestate.NewStore(report.RegistryPath).Load(ctx)
276 if err != nil {
277 return err
278 }
279 // Startup alone never imports the restored legacy tab; the restart launch
280 // asks the banner to prepare it, which imports it into one session. The
281 // JSONL bytes may be rewritten by shutdown, so authored content is checked.
282 switch phase {
283 case "first":
284 if len(state.SourceMappings) != 0 || len(state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs) != 0 || len(state.PendingOperations) != 0 {
285 return errors.New("startup imported or recreated the legacy session")
286 }
287 if err := verifyRestoredLegacyTab(report); err != nil {
288 return err
289 }
290 case "restart":
291 sessionID, err := verifyPreparedImport(&state, report)
292 if err != nil {
293 return err
294 }
295 if err := verifyPreparedTab(report, sessionID); err != nil {
296 return err
297 }
298 default:
299 return errors.New("--phase must be first or restart")
300 }
301 if err := verifyLegacyHistory(report.LegacyPath, fixtureQuestion, report.VisibleText); err != nil {
302 return err
303 }
304 for path, marker := range map[string]string{config.DesktopTopicStatePath(""): "global", config.DesktopTopicStatePath(report.ProjectRoot): "project"} {
305 if err := verifyTopicDatabase(ctx, path, marker); err != nil {
306 return err
307 }
308 }
309 if err := verifyBackups(ctx, report); err != nil {
310 return err
311 }
312 resultPath := filepath.Join(filepath.Dir(reportPath), "verification-"+phase+".json")
313 if phase == "restart" {
314 var first struct {
315 LegacyPath string `json:"legacyPath"`
316 }
317 if err := readJSON(filepath.Join(filepath.Dir(reportPath), "verification-first.json"), &first); err != nil {
318 return err
319 }
320 if agent.CanonicalSessionPath(first.LegacyPath) != agent.CanonicalSessionPath(report.LegacyPath) {
321 return errors.New("restart changed legacy session identity")
322 }
323 }
324 return writeJSON(resultPath, map[string]any{"phase": phase, "version": state.Version, "legacyPath": report.LegacyPath, "history": report.VisibleText, "topicBackups": 2, "unknownData": "preserved", "verifiedAt": time.Now().UTC()})
325 }
326
327 // verifyPreparedImport returns the one global session the legacy source became.
328 func verifyPreparedImport(state *workspacestate.State, report fixtureReport) (string, error) {
329 if len(state.SourceMappings) != 1 {
330 return "", fmt.Errorf("source mappings=%d, want exactly the legacy source", len(state.SourceMappings))
331 }
332 var mapping workspacestate.SourceMapping
333 for _, only := range state.SourceMappings {
334 mapping = only
335 }
336 if mapping.Format != "legacy" || agent.CanonicalSessionPath(mapping.Path) != agent.CanonicalSessionPath(report.LegacyPath) {
337 return "", fmt.Errorf("imported source %q (%s) is not the fixture legacy session", mapping.Path, mapping.Format)
338 }
339 sessions := state.Workspaces[workspacestate.GlobalWorkspaceID].SessionIDs
340 if mapping.SessionID == "" || mapping.WorkspaceID != workspacestate.GlobalWorkspaceID || len(sessions) != 1 || sessions[0] != mapping.SessionID {
341 return "", fmt.Errorf("imported session %q in %q, global sessions=%v", mapping.SessionID, mapping.WorkspaceID, sessions)
342 }
343 for id, op := range state.PendingOperations {
344 if op.Phase != "committed" {
345 return "", fmt.Errorf("operation %s left open in phase %q", id, op.Phase)
346 }
347 }
348 return mapping.SessionID, nil
349 }
350
351 func verifyPreparedTab(report fixtureReport, sessionID string) error {
352 var saved struct {
353 ActiveTab string `json:"activeTab"`
354 Tabs []struct {
355 ID string `json:"id"`
356 TopicID string `json:"topicId"`
357 SessionID string `json:"sessionId"`
358 } `json:"tabs"`
359 }
360 if err := readJSON(filepath.Join(config.ReasonixHomeDir(), "desktop-tabs.json"), &saved); err != nil {
361 return err
362 }
363 for _, tab := range saved.Tabs {
364 if tab.ID == saved.ActiveTab && tab.TopicID == report.TopicID && tab.SessionID == sessionID {
365 return nil
366 }
367 }
368 return errors.New("active tab does not carry the imported session and its topic")
369 }
370
371 func verifyLegacyHistory(path, question, answer string) error {
372 messages, _, repairable, err := agent.LoadSessionDisplayMessages(path)
373 if err != nil {
374 return err
375 }
376 if !repairable {
377 return errors.New("legacy history has a damaged authoritative tail")
378 }
379 authored := make([]provider.Message, 0, 2)
380 for _, message := range messages {
381 if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant {
382 authored = append(authored, message)
383 }
384 }
385 if len(authored) != 2 || authored[0].Role != provider.RoleUser || authored[0].Content != question || authored[1].Role != provider.RoleAssistant || authored[1].Content != answer {
386 return errors.New("legacy authored history changed")
387 }
388 return nil
389 }
390
391 func verifyRestoredLegacyTab(report fixtureReport) error {
392 var saved struct {
393 ActiveTab string `json:"activeTab"`
394 Tabs []struct {
395 ID string `json:"id"`
396 TopicID string `json:"topicId"`
397 SessionPath string `json:"sessionPath"`
398 } `json:"tabs"`
399 }
400 if err := readJSON(filepath.Join(config.ReasonixHomeDir(), "desktop-tabs.json"), &saved); err != nil {
401 return err
402 }
403 for _, tab := range saved.Tabs {
404 if tab.ID == saved.ActiveTab && tab.TopicID == report.TopicID && agent.CanonicalSessionPath(tab.SessionPath) == agent.CanonicalSessionPath(report.LegacyPath) {
405 return nil
406 }
407 }
408 return errors.New("restored tab lost its legacy source or topic")
409 }
410
411 func verifyBackups(ctx context.Context, report fixtureReport) error {
412 backupRoot := filepath.Join(config.ReasonixHomeDir(), "desktop", "upgrade-backups")
413 topicBackups, err := filepath.Glob(filepath.Join(backupRoot, "topics-*.sqlite"))
414 if err != nil {
415 return fmt.Errorf("list topic backups: %w", err)
416 }
417 if len(topicBackups) != 2 {
418 return fmt.Errorf("topic backups=%d, want 2", len(topicBackups))
419 }
420 markers := map[string]bool{}
421 for _, path := range topicBackups {
422 marker, err := topicMarker(ctx, path)
423 if err != nil {
424 return err
425 }
426 markers[marker] = true
427 }
428 if !markers["global"] || !markers["project"] {
429 return fmt.Errorf("topic backup markers=%v", markers)
430 }
431 // Startup may snapshot the newer registry too. Bind acceptance to the
432 // immutable original instead of assuming no later snapshot can exist.
433 backupPath := filepath.Join(backupRoot, "workspace-state-v1.json-"+report.RegistrySHA256+".bak")
434 backupBody, err := os.ReadFile(backupPath)
435 if err != nil {
436 return err
437 }
438 digest := sha256.Sum256(backupBody)
439 if hex.EncodeToString(digest[:]) != report.RegistrySHA256 {
440 return errors.New("registry backup does not match the v1 source")
441 }
442 return nil
443 }
444
445 func verifyTopicDatabase(ctx context.Context, path, wantMarker string) error {
446 marker, err := topicMarker(ctx, path)
447 if err != nil {
448 return err
449 }
450 if marker != wantMarker {
451 return fmt.Errorf("topic database %s marker=%q, want %q", path, marker, wantMarker)
452 }
453 return nil
454 }
455
456 func topicMarker(ctx context.Context, path string) (string, error) {
457 dsn, err := sqliteuri.Disk(path, url.Values{"mode": {"ro"}})
458 if err != nil {
459 return "", err
460 }
461 db, err := sql.Open("sqlite", dsn)
462 if err != nil {
463 return "", err
464 }
465 defer db.Close()
466 var marker, future string
467 if err := db.QueryRowContext(ctx, `SELECT marker FROM future_data`).Scan(&marker); err != nil {
468 return "", err
469 }
470 if err := db.QueryRowContext(ctx, `SELECT future_column FROM topics LIMIT 1`).Scan(&future); err != nil {
471 return "", err
472 }
473 if future != "future-"+marker {
474 return "", fmt.Errorf("future topic column=%q for marker %q", future, marker)
475 }
476 return marker, nil
477 }
478
479 func writeJSON(path string, value any) error {
480 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
481 return err
482 }
483 body, err := json.MarshalIndent(value, "", " ")
484 if err != nil {
485 return err
486 }
487 return os.WriteFile(path, append(body, '\n'), 0o600)
488 }
489
490 func readJSON(path string, value any) error {
491 body, err := os.ReadFile(path)
492 if err != nil {
493 return err
494 }
495 return json.Unmarshal(body, value)
496 }
497
497 lines GO