返回 DeepSeek-Reasonix
import_resolver.go
根目录 / internal / session / import_resolver.go
1 package session
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "io/fs"
10 "os"
11 "path/filepath"
12 "reflect"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/provider"
16 )
17
18 var ErrImportConflict = errors.New("legacy transcript and session event source conflict")
19
20 type ImportResult struct {
21 TargetID string
22 Source Source
23 Reused bool
24 Kind string
25 }
26
27 // MigrationHistoryContains compares durable transcript meaning using the same
28 // normalization as paired imports. Callers must establish provenance first;
29 // matching text alone is not proof that two sessions share an identity.
30 func MigrationHistoryContains(history, prefix []provider.Message) bool {
31 history, prefix = comparableImportMessages(history), comparableImportMessages(prefix)
32 if len(prefix) > len(history) {
33 return false
34 }
35 for index := range prefix {
36 if !reflect.DeepEqual(history[index], prefix[index]) {
37 return false
38 }
39 }
40 return true
41 }
42
43 func importSourceForLegacy(ctx context.Context, sourcePath, targetRoot, headID string) (ImportResult, error) {
44 return importSourceForLegacyWithHeader(ctx, sourcePath, targetRoot, headID, CreateOptions{})
45 }
46
47 func importSourceForLegacyWithHeader(ctx context.Context, sourcePath, targetRoot, headID string, options CreateOptions) (ImportResult, error) {
48 return importSourceForLegacyFrom(ctx, sourcePath, targetRoot, targetRoot, headID, options)
49 }
50
51 func importSourceForLegacyFrom(ctx context.Context, sourcePath, sourceRoot, targetRoot, headID string, options CreateOptions) (ImportResult, error) {
52 return importSourceForLegacyAt(ctx, sourcePath, filepath.Join(sourceRoot, agent.BranchID(sourcePath)), targetRoot, headID, options)
53 }
54
55 func importSourceForLegacyAt(ctx context.Context, sourcePath, previewDir, targetRoot, headID string, options CreateOptions) (ImportResult, error) {
56 // The identity cutover deliberately reuses BranchID(sourcePath) for the
57 // canonical runtime. Once a final v4 store exists at that identity it is
58 // authoritative: treating it as a retired "paired preview" both rejects a
59 // valid codec and can remigrate an older checkpoint over newer v4 work.
60 if final, finalErr := readManifest(filepath.Join(previewDir, "manifest.json")); finalErr == nil {
61 if final.SessionID != filepath.Base(previewDir) {
62 return ImportResult{}, fmt.Errorf("session: canonical store identity %q does not match directory identity %q", final.SessionID, filepath.Base(previewDir))
63 }
64 source := Source{Path: sourcePath, Version: Codec}
65 if final.Source != nil {
66 source = *final.Source
67 }
68 if err := validateSessionHeaderForCreate(previewDir, final.SessionID, options); err != nil {
69 return ImportResult{}, err
70 }
71 return ImportResult{TargetID: final.SessionID, Source: source, Reused: true, Kind: "final"}, nil
72 }
73 if _, statErr := os.Stat(previewDir); errors.Is(statErr, fs.ErrNotExist) {
74 frozenLegacy, err := freezeLegacyHead(ctx, sourcePath, headID, true)
75 if err != nil {
76 return ImportResult{}, err
77 }
78 defer os.RemoveAll(frozenLegacy.freezeDir)
79 return publishLegacyImportWithHeader(ctx, frozenLegacy, targetRoot, options)
80 }
81 // Freeze and parse every candidate before publication. Inspecting a paired
82 // sidecar after publishing legacy history can omit newer work and leave an
83 // adopted target behind after a refused import.
84 frozenLegacy, err := freezeLegacyHead(ctx, sourcePath, headID, true)
85 if err != nil {
86 return ImportResult{}, err
87 }
88 defer os.RemoveAll(frozenLegacy.freezeDir)
89 frozenPreview, err := freezePairedPreview(ctx, previewDir)
90 if errors.Is(err, fs.ErrNotExist) {
91 // No paired sidecar (or no target root yet) means the transcript is the
92 // only candidate. Nothing has been published at this point.
93 return publishLegacyImportWithHeader(ctx, frozenLegacy, targetRoot, options)
94 }
95 if err != nil {
96 return ImportResult{}, fmt.Errorf("inspect paired session events: %w", err)
97 }
98 defer os.RemoveAll(frozenPreview.freezeDir)
99 preview, meaningful, err := inspectFrozenPreview(ctx, frozenPreview)
100 if err != nil {
101 return ImportResult{}, fmt.Errorf("inspect paired session events: %w", err)
102 }
103 if !meaningful {
104 return publishLegacyImportWithHeader(ctx, frozenLegacy, targetRoot, options)
105 }
106
107 relation, legacyMessages, firstDifference, err := compareLegacySpool(frozenLegacy.messageSpool, preview)
108 if err != nil {
109 return ImportResult{}, err
110 }
111 switch relation {
112 case importMessagesEqual, importLegacyPrefix:
113 // The event sidecar carries the same history or a strictly longer one,
114 // so it is the only source that can be resumed without losing work.
115 imported, importErr := importFrozenPreview(ctx, frozenPreview, targetRoot, options)
116 return ImportResult{TargetID: imported.TargetID, Source: imported.Source, Reused: imported.Reused, Kind: "events"}, importErr
117 case importPreviewPrefix:
118 // The transcript is strictly newer; the sidecar is an earlier prefix.
119 return publishLegacyImportWithHeader(ctx, frozenLegacy, targetRoot, options)
120 default:
121 // Neither source is a provable prefix of the other. Both originals stay
122 // read-only and no executable target is created.
123 return ImportResult{}, fmt.Errorf("%w: legacy=%s events=%s legacy_messages=%d event_messages=%d first_difference=%d", ErrImportConflict, sourcePath, frozenPreview.source.Version, legacyMessages, len(comparableImportMessages(preview)), firstDifference)
124 }
125 }
126
127 type importMessageRelation uint8
128
129 const (
130 importMessagesEqual importMessageRelation = iota
131 importLegacyPrefix
132 importPreviewPrefix
133 importMessagesConflict
134 )
135
136 // compareLegacySpool proves the same prefix relation as the old in-memory
137 // comparison while retaining only one legacy message at a time.
138 func compareLegacySpool(path string, preview []provider.Message) (importMessageRelation, int, int, error) {
139 preview = comparableImportMessages(preview)
140 file, err := os.Open(path)
141 if err != nil {
142 return importMessagesConflict, 0, 0, err
143 }
144 defer file.Close()
145 decoder := json.NewDecoder(file)
146 legacyCount := 0
147 firstDifference := -1
148 for {
149 var message provider.Message
150 err := decoder.Decode(&message)
151 if errors.Is(err, io.EOF) {
152 break
153 }
154 if err != nil {
155 return importMessagesConflict, legacyCount, max(firstDifference, 0), err
156 }
157 comparable := comparableImportMessages([]provider.Message{message})
158 if len(comparable) == 0 {
159 continue
160 }
161 if firstDifference < 0 && legacyCount < len(preview) && !reflect.DeepEqual(comparable[0], preview[legacyCount]) {
162 firstDifference = legacyCount
163 }
164 legacyCount++
165 }
166 if firstDifference >= 0 {
167 return importMessagesConflict, legacyCount, firstDifference, nil
168 }
169 switch {
170 case legacyCount == len(preview):
171 return importMessagesEqual, legacyCount, legacyCount, nil
172 case legacyCount < len(preview):
173 return importLegacyPrefix, legacyCount, legacyCount, nil
174 default:
175 return importPreviewPrefix, legacyCount, len(preview), nil
176 }
177 }
178
179 // publishLegacyImportWithHeader materializes the transcript target. It runs
180 // only after the source decision is final, so a refused or sidecar-winning
181 // import never creates a target or immutable Header as a side effect.
182 func publishLegacyImportWithHeader(ctx context.Context, frozen *frozenLegacyHead, targetRoot string, options CreateOptions) (ImportResult, error) {
183 migration, err := frozen.publish(ctx, targetRoot, options)
184 if err != nil {
185 return ImportResult{}, err
186 }
187 return importResultFromLegacy(migration), nil
188 }
189
190 func importResultFromLegacy(result MigrationResult) ImportResult {
191 return ImportResult{TargetID: result.TargetID, Source: result.Source, Reused: result.Reused, Kind: "legacy"}
192 }
193
194 func inspectFrozenPreview(ctx context.Context, frozen frozenPreview) ([]provider.Message, bool, error) {
195 projection := Projection{}
196 meaningful := false
197 var projectionErr error
198 knownKinds := ProjectionKinds
199 if frozen.manifest.Codec == PrototypeCodec {
200 knownKinds = PrototypeProjectionKinds
201 }
202 file, err := os.Open(frozen.eventPath)
203 if err != nil {
204 return nil, false, err
205 }
206 visit := func(_ int64, commit Commit) bool {
207 if ctx.Err() != nil {
208 return false
209 }
210 converted := cloneCommit(commit)
211 for i := range converted.Events {
212 kind := converted.Events[i].Kind
213 if frozen.manifest.Codec == PrototypeCodec && kind == "context/replace" {
214 converted.Events[i].Kind = "history/replace"
215 kind = "history/replace"
216 }
217 switch kind {
218 case "session/config", "session/title", "diagnostic":
219 default:
220 meaningful = true
221 }
222 }
223 if applyErr := applyProjectionCommit(&projection, converted); applyErr != nil {
224 projectionErr = applyErr
225 return false
226 }
227 return true
228 }
229 if frozen.manifest.Codec == Codec && frozen.manifest.StorageRevision == 0 {
230 err = scanV4CommitFile(ctx, file, 0, 1, contentStoreForSessionDir(frozen.dir), knownKinds, visit)
231 } else {
232 err = scanCommitFileCodec(file, 0, 1, frozen.manifest.Codec, knownKinds, visit)
233 }
234 _ = file.Close()
235 if err != nil {
236 return nil, false, err
237 }
238 if projectionErr != nil {
239 return nil, false, projectionErr
240 }
241 if ctx.Err() != nil {
242 return nil, false, ctx.Err()
243 }
244 return projection.Messages, meaningful, nil
245 }
246
247 func messagesEqual(left, right []provider.Message) bool {
248 return messageSequenceEqual(comparableImportMessages(left), comparableImportMessages(right))
249 }
250
251 func messagesPrefix(prefix, whole []provider.Message) bool {
252 prefix = comparableImportMessages(prefix)
253 whole = comparableImportMessages(whole)
254 return len(prefix) <= len(whole) && messageSequenceEqual(prefix, whole[:len(prefix)])
255 }
256
257 func messageSequenceEqual(left, right []provider.Message) bool {
258 if len(left) != len(right) {
259 return false
260 }
261 for i := range left {
262 if !reflect.DeepEqual(left[i], right[i]) {
263 return false
264 }
265 }
266 return true
267 }
268
269 // comparableImportMessages keeps stable identity and provider-visible work but
270 // removes process-local diagnostics. Legacy snapshots may record a completed
271 // tool recovery receipt after the typed event mirror has already committed the
272 // same call; that metadata cannot authorize resumed execution and must not turn
273 // identical work into a migration conflict.
274 func comparableImportMessages(messages []provider.Message) []provider.Message {
275 messages = provider.ModelMessages(messages)
276 out := append([]provider.Message(nil), messages...)
277 for i := range out {
278 out[i].MemoryCitations = nil
279 out[i].WorkDurationMs = 0
280 out[i].CreatedAt = 0
281 out[i].Edited = false
282 out[i].Original = ""
283 }
284 return out
285 }
286
286 lines GO