返回 DeepSeek-Reasonix
topic_migration_marker.go
根目录 / desktop / topic_migration_marker.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13
14 "reasonix/internal/store"
15 )
16
17 // topicMigrationMarker records a completed legacy→topic migration for the
18 // directory's current session signature. New CLI sessions and same-size
19 // rewrites change the signature and force re-migration without relying only on
20 // coarse directory mtimes.
21 // v2 also re-evaluates recovery-named sessions that v1 skipped by filename.
22 const topicMigrationMarker = ".topics-migrated-v2"
23 const topicIndexRepairMarker = ".topic-indexes-repaired-v2"
24
25 const (
26 migrationFingerprintWindow = int64(2 << 10)
27 migrationFullFingerprintLimit = int64(256 << 10)
28 )
29
30 func invalidateTopicDirMarkers(dir string) error {
31 dir = strings.TrimSpace(dir)
32 if dir == "" {
33 return nil
34 }
35 var errs []error
36 for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} {
37 if err := os.Remove(filepath.Join(dir, marker)); err != nil && !os.IsNotExist(err) {
38 errs = append(errs, err)
39 }
40 }
41 return errors.Join(errs...)
42 }
43
44 func topicDirMarkerDone(dir, marker string) bool {
45 dir = strings.TrimSpace(dir)
46 marker = strings.TrimSpace(marker)
47 if dir == "" || marker == "" {
48 return false
49 }
50 data, err := os.ReadFile(filepath.Join(dir, marker))
51 if err != nil {
52 return false
53 }
54 sig, err := sessionDirMigrationSignature(dir)
55 if err != nil {
56 // Transient directory read failure: treat as not done so the next
57 // reconcile retries rather than permanently skipping migration.
58 return false
59 }
60 // Accept both signature content and legacy empty markers that still match
61 // only when the directory has no session files (empty sig of empty dir).
62 got := strings.TrimSpace(string(data))
63 if got == "" {
64 // Legacy empty marker: valid only when the dir currently has no
65 // migratable session/meta files. Any new transcript must re-run.
66 return sig == emptySessionDirSignature
67 }
68 return got == sig
69 }
70
71 const emptySessionDirSignature = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
72
73 func sessionDirMigrationSignature(dir string) (string, error) {
74 entries, err := os.ReadDir(dir)
75 if err != nil {
76 return "", err
77 }
78 lines := make([]string, 0, len(entries))
79 for _, entry := range entries {
80 if entry.IsDir() {
81 continue
82 }
83 name := entry.Name()
84 if !migrationSignatureArtifact(name) {
85 continue
86 }
87 record, err := migrationArtifactSignature(filepath.Join(dir, name), name)
88 if err != nil {
89 return "", err
90 }
91 lines = append(lines, record)
92 }
93 sort.Strings(lines)
94 sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
95 return hex.EncodeToString(sum[:]), nil
96 }
97
98 func migrationSignatureArtifact(name string) bool {
99 return store.IsSessionTranscriptName(name) ||
100 strings.HasSuffix(name, ".events.jsonl") ||
101 strings.HasSuffix(name, ".jsonl.meta")
102 }
103
104 // migrationArtifactSignature hashes bounded transcript windows so a large
105 // history is never fully read. Metadata gets a full digest under the size cap;
106 // mtime, size, prefix, and tail cover ordinary and restored-time rewrites.
107 func migrationArtifactSignature(path, name string) (string, error) {
108 f, err := os.Open(path)
109 if err != nil {
110 return "", err
111 }
112 defer f.Close()
113
114 before, err := f.Stat()
115 if err != nil {
116 return "", err
117 }
118 if before.IsDir() {
119 return "", fmt.Errorf("migration signature artifact %q is a directory", path)
120 }
121 digest, err := migrationArtifactContentDigest(f, before.Size(), strings.HasSuffix(name, ".jsonl.meta"))
122 if err != nil {
123 return "", err
124 }
125 after, err := f.Stat()
126 if err != nil {
127 return "", err
128 }
129 if before.Size() != after.Size() || !before.ModTime().Equal(after.ModTime()) {
130 return "", fmt.Errorf("migration signature artifact changed while reading: %q", path)
131 }
132 return fmt.Sprintf("%q\t%d\t%d\t%s", name, before.Size(), before.ModTime().UnixNano(), digest), nil
133 }
134
135 func migrationArtifactContentDigest(f *os.File, size int64, preferFull bool) (string, error) {
136 h := sha256.New()
137 if size <= migrationFingerprintWindow*2 || (preferFull && size <= migrationFullFingerprintLimit) {
138 if _, err := f.Seek(0, io.SeekStart); err != nil {
139 return "", err
140 }
141 if _, err := io.CopyN(h, f, size); err != nil {
142 return "", err
143 }
144 return hex.EncodeToString(h.Sum(nil)), nil
145 }
146 for _, sample := range []struct {
147 offset int64
148 length int64
149 }{
150 {offset: 0, length: migrationFingerprintWindow},
151 {offset: size - migrationFingerprintWindow, length: migrationFingerprintWindow},
152 } {
153 if _, err := fmt.Fprintf(h, "@%d:%d\n", sample.offset, sample.length); err != nil {
154 return "", err
155 }
156 if _, err := f.Seek(sample.offset, io.SeekStart); err != nil {
157 return "", err
158 }
159 if _, err := io.CopyN(h, f, sample.length); err != nil {
160 return "", err
161 }
162 }
163 return hex.EncodeToString(h.Sum(nil)), nil
164 }
165
166 func topicMigrationDone(dir string) bool {
167 return topicDirMarkerDone(dir, topicMigrationMarker)
168 }
169
170 func topicIndexRepairDone(dir string) bool {
171 return topicDirMarkerDone(dir, topicIndexRepairMarker)
172 }
173
174 func markTopicDirMarkerDone(dir, marker string) {
175 dir = strings.TrimSpace(dir)
176 marker = strings.TrimSpace(marker)
177 if dir == "" || marker == "" {
178 return
179 }
180 if err := os.MkdirAll(dir, 0o755); err != nil {
181 return
182 }
183 sig, err := sessionDirMigrationSignature(dir)
184 if err != nil {
185 return
186 }
187 _ = os.WriteFile(filepath.Join(dir, marker), []byte(sig+"\n"), 0o644)
188 }
189
190 func markTopicMigrationDone(dir string) {
191 markTopicDirMarkerDone(dir, topicMigrationMarker)
192 }
193
194 func markTopicIndexRepairDone(dir string) {
195 markTopicDirMarkerDone(dir, topicIndexRepairMarker)
196 }
197
197 lines GO