返回 DeepSeek-Reasonix
session_source_compatibility.go
根目录 / desktop / session_source_compatibility.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14
15 "reasonix/desktop/internal/workspacestate"
16 "reasonix/internal/agent"
17 "reasonix/internal/config"
18 "reasonix/internal/fileutil"
19 "reasonix/internal/session"
20 "reasonix/internal/topicstate"
21 )
22
23 func desktopSourceKey(path, head string) string {
24 // Migration sources include both transcript files and canonical prototype
25 // directories. Keep their persisted key independent from the runtime
26 // session locator, which intentionally accepts transcript paths only.
27 pathKey := agent.CanonicalSessionPath(cleanDesktopPath(path))
28 sum := sha256.Sum256([]byte(pathKey + "\x00" + head))
29 return hex.EncodeToString(sum[:])
30 }
31
32 func (source desktopMigrationSource) mappingKey(path string) string {
33 key := desktopSourceKey(path, source.headID)
34 if source.versionFingerprint != "" {
35 key += ":review:" + source.versionFingerprint
36 }
37 return key
38 }
39
40 // Fingerprints cover source bytes, not the destination's evolving projection.
41 // A continued canonical session must never be replaced by its frozen import.
42 func desktopSourceFingerprint(path string) (string, error) {
43 info, err := os.Lstat(path)
44 if err != nil {
45 return "", err
46 }
47 if info.Mode()&os.ModeSymlink != 0 {
48 return "", errors.New("session source is a symbolic link")
49 }
50 paths := []string{}
51 if info.IsDir() {
52 for _, name := range []string{"manifest.json", "header.json", "events.frames", "events.jsonl"} {
53 candidate := filepath.Join(path, name)
54 if _, err := os.Lstat(candidate); err == nil {
55 paths = append(paths, candidate)
56 } else if !os.IsNotExist(err) {
57 return "", err
58 }
59 }
60 } else {
61 paths = append(paths, path)
62 for _, artifact := range sessionTrashArtifacts(path, filepath.Base(path)) {
63 if artifact.src == path {
64 continue
65 }
66 // DAG selection lives in the event log. Branch .meta also contains
67 // mutable catalog/title projections; hashing it would reject a source
68 // simply because background indexing refreshed its display metadata.
69 if strings.HasSuffix(artifact.name, ".events.jsonl") {
70 if _, err := os.Lstat(artifact.src); err == nil {
71 paths = append(paths, artifact.src)
72 } else if !os.IsNotExist(err) {
73 return "", err
74 }
75 }
76 }
77 }
78 if len(paths) == 0 {
79 return "", errors.New("session source has no durable records")
80 }
81 sort.Strings(paths)
82 h := sha256.New()
83 for _, file := range paths {
84 info, err := os.Lstat(file)
85 if err != nil {
86 return "", err
87 }
88 if !info.Mode().IsRegular() {
89 return "", errors.New("session source contains a non-regular file")
90 }
91 fmt.Fprintf(h, "%s\x00%d\x00", filepath.Base(file), info.Size())
92 f, err := os.Open(file)
93 if err != nil {
94 return "", err
95 }
96 _, copyErr := io.Copy(h, f)
97 closeErr := f.Close()
98 if copyErr != nil {
99 return "", copyErr
100 }
101 if closeErr != nil {
102 return "", closeErr
103 }
104 }
105 return hex.EncodeToString(h.Sum(nil)), nil
106 }
107
108 func (a *App) recordDesktopSource(ctx context.Context, path, format, fingerprint, targetID, workspaceID string) error {
109 presentation := workspacestate.Presentation{SortOrder: -1, TopicID: legacySessionTopicID(path)}
110 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok {
111 if meta.TopicID != "" {
112 presentation.TopicID = meta.TopicID
113 }
114 presentation.Title = meta.TopicTitle
115 }
116 presentation = a.historicalTopicPresentation(ctx, workspaceID, presentation)
117 return a.workspaceRegistry().RecordSource(ctx, workspacestate.SourceMapping{
118 SourceKey: desktopSourceKey(path, ""), Path: path, Format: format, Fingerprint: fingerprint,
119 SessionID: targetID, WorkspaceID: workspaceID,
120 }, presentation)
121 }
122
123 func (a *App) commitDesktopImport(ctx context.Context, source desktopMigrationSource, path, format, fingerprint, targetID, workspaceID string) error {
124 current, err := desktopSourceFingerprint(path)
125 if err != nil {
126 return err
127 }
128 if current != fingerprint {
129 return workspacestate.ErrMutationConflict
130 }
131 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: targetID}
132 if err := a.validateDesktopWorkspaceMembership(ctx, workspaceID, ref); err != nil {
133 return err
134 }
135 if _, err := a.desktopSessionService("").Query().Snapshot(ctx, ref); err != nil {
136 return err
137 }
138 key := source.mappingKey(path)
139 mapping := workspacestate.SourceMapping{SourceKey: key, Path: path, HeadID: source.headID, Format: format, Fingerprint: fingerprint, SessionID: targetID, WorkspaceID: workspaceID}
140 mapping.RetainedArtifacts, err = retainedDesktopArtifacts(path)
141 if err != nil {
142 return err
143 }
144 presentation := workspacestate.Presentation{SortOrder: -1}
145 if format == "legacy" {
146 presentation.TopicID = legacySessionTopicID(path)
147 }
148 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok {
149 if meta.TopicID != "" {
150 presentation.TopicID = meta.TopicID
151 }
152 presentation.Title = meta.TopicTitle
153 }
154 presentation = a.historicalTopicPresentation(ctx, workspaceID, presentation)
155 opID, err := a.prepareDesktopImport(ctx, source, path, fingerprint, targetID, workspaceID)
156 if err != nil {
157 return err
158 }
159 if err := a.workspaceRegistry().PrepareOperationContent(ctx, opID, []string{targetID}, &mapping, &presentation); err != nil {
160 return err
161 }
162 if source.deferArchive {
163 return nil
164 }
165 return a.workspaceRegistry().CommitOperation(ctx, opID)
166 }
167
168 func (a *App) historicalTopicPresentation(ctx context.Context, workspaceID string, presentation workspacestate.Presentation) workspacestate.Presentation {
169 projects := loadProjectsFile()
170 if workspaceID == workspacestate.GlobalWorkspaceID {
171 return historicalTopicPresentationFrom(projects, workspaceID, "", presentation)
172 }
173 workspaceRoot := ""
174 if state, err := a.workspaceRegistry().Load(ctx); err == nil {
175 workspaceRoot = state.Workspaces[workspaceID].Root
176 }
177 return historicalTopicPresentationFrom(projects, workspaceID, workspaceRoot, presentation)
178 }
179
180 func historicalTopicPresentationFrom(projects desktopProjectFile, workspaceID, workspaceRoot string, presentation workspacestate.Presentation) workspacestate.Presentation {
181 topics, pinned := projects.GlobalTopics, projects.GlobalPinnedTopics
182 if workspaceID != workspacestate.GlobalWorkspaceID {
183 topics, pinned = nil, nil
184 for _, project := range projects.Projects {
185 if (workspaceRoot != "" && sameProjectRoot(project.Root, workspaceRoot)) ||
186 (workspaceRoot == "" && desktopWorkspaceID("project", project.Root) == workspaceID) {
187 topics, pinned = project.Topics, project.PinnedTopics
188 break
189 }
190 }
191 }
192 presentation.Pinned = containsDesktopString(pinned, presentation.TopicID)
193 for rank, topic := range pinnedTopicIDs(topics, pinned) {
194 if topic == presentation.TopicID {
195 presentation.SortOrder = rank
196 break
197 }
198 }
199 return presentation
200 }
201
202 // These references are local recovery evidence, never telemetry. Directories
203 // retain their complete subtree; import success does not authorize cleanup.
204 func retainedDesktopArtifacts(path string) ([]string, error) {
205 info, err := os.Lstat(path)
206 if err != nil {
207 return nil, err
208 }
209 if info.IsDir() {
210 return []string{path}, nil
211 }
212 retained := []string{}
213 for _, artifact := range sessionTrashArtifacts(path, filepath.Base(path)) {
214 if _, err := os.Lstat(artifact.src); err == nil {
215 retained = append(retained, artifact.src)
216 } else if !os.IsNotExist(err) {
217 return nil, err
218 }
219 }
220 return retained, nil
221 }
222
223 // Reserve the destination before publishing content, so restart reconciliation
224 // cannot mistake an interrupted import for an ordinary unregistered session.
225 func (a *App) prepareDesktopImport(ctx context.Context, source desktopMigrationSource, path, fingerprint, targetID, workspaceID string) (string, error) {
226 opID := source.operationID
227 if opID == "" {
228 opID = "import-" + desktopSourceKey(path, source.headID) + "-" + fingerprint
229 if source.deferArchive {
230 opID = "archive-" + opID
231 }
232 state, err := a.workspaceRegistry().Load(ctx)
233 if err != nil {
234 return "", err
235 }
236 if source.versionFingerprint != "" {
237 // Older builds used the ordinary import ID for versioned mappings.
238 // Resume that exact reservation when present, but do not collide
239 // with an ordinary import of the same source fingerprint.
240 previous, exists := state.PendingOperations[opID]
241 if !exists || previous.Mapping == nil || previous.Mapping.SourceKey != source.mappingKey(path) {
242 opID = "review-" + opID
243 }
244 }
245 lifecycle := workspacestate.Active
246 kind := "import"
247 if source.deferArchive {
248 kind, lifecycle = "archive-import", workspacestate.Archived
249 }
250 if previous, ok := state.SessionStates[targetID]; ok {
251 lifecycle = previous.Lifecycle
252 }
253 mapping := &workspacestate.SourceMapping{SourceKey: source.mappingKey(path), Path: path, HeadID: source.headID, Fingerprint: fingerprint, SessionID: targetID, WorkspaceID: workspaceID}
254 if err := a.workspaceRegistry().BeginOperation(ctx, workspacestate.Operation{ID: opID, Kind: kind, WorkspaceID: workspaceID, SessionIDs: []string{targetID}, Mapping: mapping, Lifecycle: lifecycle, ExpectedGeneration: state.Generation}); err != nil {
255 return "", err
256 }
257 }
258 format := "legacy"
259 if info, err := os.Stat(path); err != nil {
260 return "", err
261 } else if info.IsDir() {
262 format = "canonical"
263 }
264 mapping := &workspacestate.SourceMapping{SourceKey: source.mappingKey(path), Path: path, HeadID: source.headID, Format: format, Fingerprint: fingerprint, SessionID: targetID, WorkspaceID: workspaceID}
265 return opID, a.workspaceRegistry().ReserveOperationTargets(ctx, opID, []string{targetID}, mapping)
266 }
267
268 func (a *App) resolveDesktopImportTarget(ctx context.Context, query *session.Query, preferredID, key, mappingKey, contentDigest, path, fingerprint string, heads ...string) (string, bool, error) {
269 headID := ""
270 if len(heads) > 0 {
271 headID = heads[0]
272 }
273 state, err := a.workspaceRegistry().Load(ctx)
274 if err != nil {
275 return "", false, err
276 }
277 for _, op := range state.PendingOperations {
278 // Explicit source versions reserve their own mapping key. Recover the
279 // exact reservation even when the imported manifest has no provenance.
280 if op.Mapping == nil || op.Mapping.SourceKey != mappingKey || op.Mapping.Fingerprint != fingerprint || len(op.SessionIDs) != 1 {
281 continue
282 }
283 id := op.SessionIDs[0]
284 digest, err := canonicalMigrationDigest(ctx, query, session.SessionRef{HostID: localDesktopHostID, SessionID: id})
285 if errors.Is(err, session.ErrSessionNotFound) {
286 return id, true, nil
287 }
288 if err != nil {
289 return "", false, err
290 }
291 if digest != contentDigest {
292 return "", false, workspacestate.ErrMutationConflict
293 }
294 return id, false, nil
295 }
296 return resolveMigrationTarget(ctx, query, preferredID, key, contentDigest, path, headID)
297 }
298
299 func (a *App) legacyCanonicalRef(ctx context.Context, path string) (session.SessionRef, bool, error) {
300 state, err := a.workspaceRegistry().Load(ctx)
301 if err != nil {
302 return session.SessionRef{}, false, err
303 }
304 mapping, adopted := state.SourceMappings[desktopSourceKey(path, "")]
305 if !adopted {
306 // DAG migration records each head separately. A path-only legacy tab
307 // still refers to the selected head, not a new import of that path.
308 for _, candidate := range state.SourceMappings {
309 if candidate.HeadID == "" || sessionRuntimeKey(candidate.Path) != sessionRuntimeKey(path) {
310 continue
311 }
312 heads, err := agent.ListSessionHeads(path)
313 if err != nil {
314 return session.SessionRef{}, false, err
315 }
316 for _, head := range heads {
317 if head.Selected && !head.Retired {
318 mapping, adopted = state.SourceMappings[desktopSourceKey(path, head.ID)]
319 break
320 }
321 }
322 break
323 }
324 }
325 if adopted {
326 if state.SessionStates[mapping.SessionID].Lifecycle == workspacestate.Deleted {
327 return session.SessionRef{}, true, session.ErrSessionNotFound
328 }
329 // Adoption is durable. Opening the new conversation must not hash or
330 // depend on the retained source, which another CLI may still be using.
331 return session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}, true, nil
332 }
333 a.mu.RLock()
334 for _, tab := range a.runtimeTabsLocked() {
335 if tab == nil || tab.Ctrl == nil {
336 continue
337 }
338 if _, runtime, exclusive := exclusiveSessionBinding(tab.Ctrl); exclusive {
339 source := runtime.Session().Manifest().Source
340 if source != nil && sessionRuntimeKey(source.Path) == sessionRuntimeKey(path) {
341 ref := runtime.Ref()
342 a.mu.RUnlock()
343 return ref, true, nil
344 }
345 }
346 }
347 a.mu.RUnlock()
348 return session.SessionRef{}, false, nil
349 }
350
351 // The source manifest remains untouched. Metadata backups are captured before
352 // any registry upgrade and are content-addressed so subsequent starts preserve
353 // every distinct pre-upgrade snapshot.
354 func (a *App) backupDesktopUpgradeMetadata(ctx context.Context) error {
355 return backupDesktopUpgradeMetadataAt(ctx, a.workspaceRegistry().Path())
356 }
357
358 func backupDesktopUpgradeMetadataAt(ctx context.Context, registryPath string) error {
359 dir := filepath.Join(desktopConfigDir(), "desktop", "upgrade-backups")
360 paths := []string{
361 registryPath, filepath.Join(desktopConfigDir(), desktopProjectsFile),
362 filepath.Join(desktopConfigDir(), tabsFileName), desktopMigrationLedgerPath(),
363 }
364 roots := []string{""}
365 for _, project := range loadProjectsFile().Projects {
366 roots = append(roots, project.Root)
367 }
368 for _, root := range roots {
369 for _, path := range legacyTopicPaths(root) {
370 paths = append(paths, path)
371 }
372 databasePath := config.DesktopTopicStatePath(root)
373 if _, err := os.Lstat(databasePath); err == nil {
374 if err := os.MkdirAll(dir, 0700); err != nil {
375 return err
376 }
377 tmp, err := os.MkdirTemp(dir, ".topic-snapshot-")
378 if err != nil {
379 return err
380 }
381 snapshot := filepath.Join(tmp, "snapshot.sqlite")
382 if err := topicstate.BackupExisting(ctx, databasePath, snapshot); err != nil {
383 _ = os.RemoveAll(tmp)
384 return err
385 }
386 body, readErr := os.ReadFile(snapshot)
387 _ = os.RemoveAll(tmp)
388 if readErr != nil {
389 return readErr
390 }
391 digest := sha256.Sum256(body)
392 dest := filepath.Join(dir, "topics-"+hex.EncodeToString(digest[:])+".sqlite")
393 if saved, err := os.ReadFile(dest); err == nil {
394 if sha256.Sum256(saved) != digest {
395 return errors.New("topic backup integrity failed")
396 }
397 } else if !os.IsNotExist(err) {
398 return err
399 } else if err := fileutil.AtomicWriteFileStrict(dest, body, 0600); err != nil {
400 return err
401 }
402 } else if !os.IsNotExist(err) {
403 return err
404 }
405 }
406 for _, path := range paths {
407 if err := ctx.Err(); err != nil {
408 return err
409 }
410 body, err := os.ReadFile(path)
411 if os.IsNotExist(err) {
412 continue
413 }
414 if err != nil {
415 return err
416 }
417 digest := sha256.Sum256(body)
418 dest := filepath.Join(dir, filepath.Base(path)+"-"+hex.EncodeToString(digest[:])+".bak")
419 if saved, err := os.ReadFile(dest); err == nil {
420 if sha256.Sum256(saved) != digest {
421 return errors.New("session upgrade backup integrity failed")
422 }
423 continue
424 } else if !os.IsNotExist(err) {
425 return err
426 }
427 if err := os.MkdirAll(dir, 0700); err != nil {
428 return err
429 }
430 if err := fileutil.AtomicWriteFileStrict(dest, body, 0600); err != nil {
431 return err
432 }
433 }
434 return nil
435 }
436
437 func (a *App) sourceRecovery(ctx context.Context, path, format, reason, scope, root string, heads ...string) error {
438 headID := ""
439 if len(heads) > 0 {
440 headID = heads[0]
441 }
442 key := desktopSourceKey(path, headID)
443 fingerprint, _ := desktopSourceFingerprint(path)
444 return a.workspaceRegistry().RecordRecovery(ctx, workspacestate.RecoveryEntry{
445 ID: desktopRecoveryID(key, fingerprint), SourceKey: key, Path: path, HeadID: headID, Format: format, Reason: reason,
446 Status: "pending", Scope: scope, WorkspaceRoot: root, Fingerprint: fingerprint,
447 })
448 }
449
450 // Preserve alternate DAG heads as independently addressable recovery choices.
451 // Importing one head never selects, retires or rewrites a head in the original.
452 func (a *App) discoverLegacyHeads(ctx context.Context, path, format, scope, root string) error {
453 heads, err := agent.ListSessionHeads(path)
454 if err != nil {
455 return errors.Join(err, a.sourceRecovery(ctx, path, format, "head_scan_failed", scope, root))
456 }
457 var joined error
458 for _, head := range heads {
459 if err := ctx.Err(); err != nil {
460 return err
461 }
462 if head.Selected || head.Retired {
463 continue
464 }
465 joined = errors.Join(joined, a.sourceRecovery(ctx, path, format, "alternate_head", scope, root, head.ID))
466 }
467 return joined
468 }
469
470 func desktopRecoveryID(key, fingerprint string) string {
471 return "legacy-" + key + "-" + fingerprint
472 }
473
474 // Read legacy ledger evidence without altering it or losing unknown fields.
475
475 lines GO