返回 DeepSeek-Reasonix
session_v5_migration_checkpoint.go
根目录 / desktop / session_v5_migration_checkpoint.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13
14 "reasonix/internal/store"
15 )
16
17 // Migration may add members to an existing workspace, but it does not own the
18 // user's title or visibility. Avoid rewriting presentation for each new source.
19 func (a *App) ensureDesktopMigrationWorkspace(ctx context.Context, source desktopMigrationSource) (string, error) {
20 state, err := a.workspaceRegistry().Load(ctx)
21 if err != nil {
22 return "", err
23 }
24 id := desktopWorkspaceOwnerID(state, source.scope, source.workspaceRoot)
25 if _, exists := state.Workspaces[id]; exists {
26 return id, nil
27 }
28 return a.ensureDesktopWorkspace(ctx, source.scope, source.workspaceRoot)
29 }
30
31 // A completed ledger entry records adoption of a source, not equality with
32 // today's target. The target can be continued, archived or deliberately deleted.
33 // None of those actions authorize importing the old conversation again.
34 type desktopMigrationCheckpoint struct {
35 key string
36 files []string
37 revision string
38 record desktopMigrationRecord
39 refresh bool
40 inputDigest string
41 }
42
43 type desktopMigrationReceipt struct {
44 extra map[string]json.RawMessage
45 TargetSessionID string `json:"targetSessionId"`
46 ContentDigest string `json:"contentDigest,omitempty"`
47 SourceRevision string `json:"sourceRevision,omitempty"`
48 }
49
50 func newDesktopMigrationCheckpoint(source desktopMigrationSource, key string, files []string) (desktopMigrationCheckpoint, error) {
51 records := source.records
52 if records == nil {
53 ledger, err := readDesktopMigrationLedger()
54 if err != nil {
55 return desktopMigrationCheckpoint{}, err
56 }
57 records = ledger.Records
58 }
59 revision, err := desktopMigrationSourceRevision(files)
60 if err != nil {
61 return desktopMigrationCheckpoint{}, errors.Join(err, updateDesktopMigrationLedger(key, records[key].TargetSessionID, "failed", "source_stat"))
62 }
63 record := records[key]
64 refresh := record.Status != "completed" && record.PreviousCompletion != nil
65 if refresh {
66 previous := record.PreviousCompletion
67 record.Status, record.TargetSessionID = "completed", previous.TargetSessionID
68 record.ContentDigest, record.SourceRevision = previous.ContentDigest, previous.SourceRevision
69 }
70 checkpoint := desktopMigrationCheckpoint{key: key, files: files, revision: revision, record: record, refresh: refresh}
71 if !checkpoint.unchanged() {
72 checkpoint.inputDigest, err = desktopMigrationInputDigest(files)
73 if err != nil {
74 return desktopMigrationCheckpoint{}, err
75 }
76 }
77 return checkpoint, nil
78 }
79
80 func (c desktopMigrationCheckpoint) completed() bool {
81 return c.record.Status == "completed" && c.record.TargetSessionID != ""
82 }
83
84 func (c desktopMigrationCheckpoint) unchanged() bool {
85 return c.completed() && c.record.SourceRevision == c.revision
86 }
87
88 func (c desktopMigrationCheckpoint) skip() error {
89 if c.refresh {
90 return c.complete(c.record.TargetSessionID, c.record.ContentDigest)
91 }
92 return nil
93 }
94
95 // Old ledgers have no source revision. Compare their recorded source digest
96 // once, without comparing to a target that may already contain newer work.
97 func (c desktopMigrationCheckpoint) matchesCompletedContent(digest string) bool {
98 return c.completed() && digest != "" && c.record.ContentDigest == digest
99 }
100
101 func (c desktopMigrationCheckpoint) complete(targetID, digest string) error {
102 revision, err := desktopMigrationSourceRevision(c.files)
103 if err == nil && revision != c.revision && c.inputDigest != "" {
104 // A catalog repair can rewrite identical JSONL bytes and refresh only
105 // derived listing fields. Verify the frozen input, not inode/mtime alone.
106 current, digestErr := desktopMigrationInputDigest(c.files)
107 if digestErr == nil && current == c.inputDigest {
108 c.revision = revision
109 }
110 err = digestErr
111 }
112 if err != nil || revision != c.revision {
113 return errors.Join(errors.New("desktop migration source changed during import"), err,
114 updateDesktopMigrationLedger(c.key, targetID, "failed", "source_changed", digest))
115 }
116 return updateDesktopMigrationLedger(c.key, targetID, "completed", "", digest, c.revision)
117 }
118
119 func canonicalMigrationSourceFiles(root, sessionID string) []string {
120 dir := filepath.Join(root, sessionID)
121 // Content blobs are immutable and referenced by the event log. Disposable
122 // indexes, checkpoints and lock files do not change the migration input.
123 return []string{filepath.Join(dir, "manifest.json"), filepath.Join(dir, "events.frames"),
124 filepath.Join(dir, "events.jsonl"), filepath.Join(dir, "header.json")}
125 }
126
127 func desktopCanonicalMigrationKey(root, sessionID string) string {
128 digest := sha256.Sum256([]byte(canonicalRuntimeRoot(root) + "\x00" + sessionID))
129 return hex.EncodeToString(digest[:])
130 }
131
132 func legacyMigrationSourceFiles(path string) []string {
133 // Catalog reads may rebuild disposable indexes while an import is frozen.
134 // Their publication is not a change to the legacy conversation. Keep all
135 // durable sidecars (including ancestry/ownership metadata) in the stamp.
136 files := []string{path}
137 for _, sidecar := range store.SessionSidecarFiles(path) {
138 if sidecar == store.SessionEventIndex(path) || sidecar == store.SessionDisplayIndex(path) || sidecar == store.SessionTranscriptProjection(path) {
139 continue
140 }
141 files = append(files, sidecar)
142 }
143 return files
144 }
145
146 func desktopLegacyMigrationKey(path string) string {
147 digest := sha256.Sum256([]byte(canonicalRuntimeRoot(path)))
148 return hex.EncodeToString(digest[:])
149 }
150
151 // This is a cheap filesystem revision, not a content integrity checksum.
152 // Normal source writes change size or mtime; no history bodies are read here.
153 // Include absent files so creation/removal of a sidecar invalidates the stamp.
154 func desktopMigrationSourceRevision(files []string) (string, error) {
155 paths := append([]string(nil), files...)
156 sort.Strings(paths)
157 hash := sha256.New()
158 for _, path := range paths {
159 info, err := os.Stat(path)
160 if os.IsNotExist(err) {
161 fmt.Fprintf(hash, "%q:missing\n", path)
162 continue
163 }
164 if err != nil {
165 return "", err
166 }
167 fmt.Fprintf(hash, "%q:%d:%d:%d\n", path, info.Mode(), info.Size(), info.ModTime().UnixNano())
168 }
169 return "stat-v1-" + hex.EncodeToString(hash.Sum(nil)), nil
170 }
171
172 func readDesktopMigrationLedger() (desktopMigrationLedger, error) {
173 desktopMigrationMu.Lock()
174 defer desktopMigrationMu.Unlock()
175 ledger, _, err := readDesktopMigrationLedgerFile()
176 return ledger, err
177 }
178
179 // Callers serialize reads and atomic replacement with desktopMigrationMu.
180 func readDesktopMigrationLedgerFile() (desktopMigrationLedger, []byte, error) {
181 ledger := desktopMigrationLedger{Version: 1, Records: map[string]desktopMigrationRecord{}}
182 body, err := os.ReadFile(desktopMigrationLedgerPath())
183 if os.IsNotExist(err) {
184 return ledger, nil, nil
185 }
186 if err != nil {
187 return ledger, nil, err
188 }
189 if err := json.Unmarshal(body, &ledger); err != nil {
190 return ledger, nil, err
191 }
192 if ledger.Version > 1 {
193 return ledger, nil, fmt.Errorf("desktop migration ledger version %d is unsupported", ledger.Version)
194 }
195 if ledger.Records == nil {
196 ledger.Records = map[string]desktopMigrationRecord{}
197 }
198 return ledger, body, nil
199 }
200
201 // Preserve unknown root/record fields when adding an optional revision to a v1
202 // ledger. Older writers can drop the revision; the digest fallback remains safe.
203 func marshalDesktopMigrationRecord(original []byte, ledger desktopMigrationLedger, key string) ([]byte, error) {
204 root := map[string]json.RawMessage{}
205 if len(original) > 0 {
206 if err := json.Unmarshal(original, &root); err != nil {
207 return nil, err
208 }
209 }
210 if root == nil {
211 root = map[string]json.RawMessage{}
212 }
213 records := map[string]map[string]json.RawMessage{}
214 if body := root["records"]; len(body) > 0 {
215 if err := json.Unmarshal(body, &records); err != nil {
216 return nil, err
217 }
218 }
219 if records == nil {
220 records = map[string]map[string]json.RawMessage{}
221 }
222 fields := records[key]
223 if fields == nil {
224 fields = map[string]json.RawMessage{}
225 }
226 for _, name := range []string{"sourceKey", "targetSessionId", "contentDigest", "sourceRevision", "previousCompletion", "status", "errorCode", "attempts", "legacyHeads", "legacySelectedHead", "legacyPrimaryHead", "legacyHeadsRevision", "legacyAdoption", "legacyConversions"} {
227 delete(fields, name)
228 }
229 body, err := json.Marshal(ledger.Records[key])
230 if err != nil {
231 return nil, err
232 }
233 if err := json.Unmarshal(body, &fields); err != nil {
234 return nil, err
235 }
236 records[key] = fields
237 root["records"], err = json.Marshal(records)
238 if err != nil {
239 return nil, err
240 }
241 root["version"], err = json.Marshal(ledger.Version)
242 if err != nil {
243 return nil, err
244 }
245 return json.MarshalIndent(root, "", " ")
246 }
247
247 lines GO