返回 DeepSeek-Reasonix
session_v5_migration.go
根目录 / desktop / session_v5_migration.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "strings"
13 "sync"
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/store"
21 )
22
23 type desktopMigrationRecord struct {
24 SourceKey string `json:"sourceKey"`
25 TargetSessionID string `json:"targetSessionId"`
26 ContentDigest string `json:"contentDigest,omitempty"`
27 SourceRevision string `json:"sourceRevision,omitempty"`
28 Status string `json:"status"`
29 ErrorCode string `json:"errorCode,omitempty"`
30 Attempts int `json:"attempts"`
31 PreviousCompletion *desktopMigrationReceipt `json:"previousCompletion,omitempty"`
32 LegacyHeads []string `json:"legacyHeads,omitempty"`
33 LegacySelectedHead string `json:"legacySelectedHead,omitempty"`
34 LegacyPrimaryHead string `json:"legacyPrimaryHead,omitempty"`
35 LegacyHeadsRevision string `json:"legacyHeadsRevision,omitempty"`
36 LegacyAdoption *desktopMigrationReceipt `json:"legacyAdoption,omitempty"`
37 LegacyConversions []desktopMigrationConversion `json:"legacyConversions,omitempty"`
38 }
39
40 type desktopMigrationLedger struct {
41 Version int `json:"version"`
42 Records map[string]desktopMigrationRecord `json:"records"`
43 }
44
45 var desktopMigrationMu sync.Mutex
46
47 func desktopMigrationLedgerPath() string {
48 return filepath.Join(desktopConfigDir(), "desktop", "session-migration-v5.json")
49 }
50
51 func (a *App) startDesktopSessionMigration(ctx context.Context) {
52 if a == nil {
53 return
54 }
55 // Capture reservations before startup admits renderer requests. Background
56 // replay must not abort a new create whose body has not been published yet.
57 startupState, err := a.workspaceRegistry().Load(ctx)
58 if err != nil {
59 a.desktopMigrationFailed.Store(true)
60 slogWarnDesktopMigration(err)
61 close(a.desktopMigrationDone)
62 return
63 }
64 c := &a.historicalImports
65 c.mu.Lock()
66 c.initialize(ctx)
67 if c.stopped || a.shuttingDown.Load() {
68 c.mu.Unlock()
69 close(a.desktopMigrationDone)
70 return
71 }
72 ctx = c.ctx
73 c.catalogEnabled = true
74 c.workers.Add(1)
75 c.mu.Unlock()
76 go func() {
77 defer c.workers.Done()
78 defer close(a.desktopMigrationDone)
79 _, _ = a.listHistoricalSessions(ctx)
80 if err := a.recoverDesktopPendingCreateSnapshot(ctx, startupState.PendingCreates); err != nil {
81 a.desktopMigrationFailed.Store(true)
82 slogWarnDesktopMigration(err)
83 }
84 // Historical content waits for an explicit request. Keep prepared
85 // reservations intact for the on-demand importer.
86 if err := a.recoverDesktopOperations(ctx, false); err != nil {
87 slogWarnDesktopMigration(err)
88 }
89 a.emitProjectTreeChanged()
90 }()
91 }
92
93 // recoverDesktopPendingCreates completes the registry half of a create that
94 // reached durable session publication before the process stopped. A missing
95 // target is safe to forget: no canonical content exists for the pending ID and
96 // the UI can retry creation without inventing a replacement identity.
97 func (a *App) recoverDesktopPendingCreates(ctx context.Context) error {
98 state, err := a.workspaceRegistry().Load(ctx)
99 if err != nil {
100 return err
101 }
102 return a.recoverDesktopPendingCreateSnapshot(ctx, state.PendingCreates)
103 }
104
105 func (a *App) recoverDesktopPendingCreateSnapshot(ctx context.Context, pendingCreates map[string]workspacestate.PendingCreate) error {
106 service := a.desktopSessionService("")
107 var joined error
108 for sessionID, pending := range pendingCreates {
109 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
110 if _, err := service.Query().Snapshot(ctx, ref); err == nil {
111 var attachErr error
112 if strings.HasPrefix(pending.OperationID, "rotate-") {
113 attachErr = a.workspaceRegistry().CommitRotation(ctx, pending.OperationID, pending.WorkspaceID, sessionID, "", pending.ArchiveSource)
114 } else {
115 attachErr = a.workspaceRegistry().AttachSession(ctx, pending.OperationID, pending.WorkspaceID, sessionID, "")
116 }
117 if attachErr != nil {
118 joined = errors.Join(joined, attachErr)
119 } else {
120 a.desktopSessions.pendingCreateRecovered.Add(1)
121 }
122 } else if errors.Is(err, session.ErrSessionNotFound) {
123 if abortErr := a.workspaceRegistry().AbortCreate(ctx, sessionID); abortErr != nil {
124 joined = errors.Join(joined, abortErr)
125 }
126 } else {
127 joined = errors.Join(joined, err)
128 }
129 }
130 return joined
131 }
132
133 func slogWarnDesktopMigration(err error) {
134 // Keep migration logs content- and path-free. Detailed per-source state is
135 // available through the local ledger and UI health row.
136 if err != nil {
137 slog.Warn("desktop session migration incomplete")
138 }
139 }
140
141 type desktopMigrationSource struct {
142 operationID string
143 headID string
144 deferArchive bool
145 versionFingerprint string
146 root string
147 scope string
148 workspaceRoot string
149 exact map[string]bool
150 records map[string]desktopMigrationRecord
151 pairedRoot string
152 pairedIDs map[string]bool
153 legacyAdoption *desktopMigrationReceipt
154 pairedAdoption *desktopMigrationReceipt
155 conversions map[string][]desktopMigrationConversion
156 headConversions []desktopMigrationConversion
157 handledStores map[string]bool
158 conversionAdoptions []*desktopMigrationReceipt
159 }
160
161 func (a *App) migrateDesktopSessionsV5(ctx context.Context) error {
162 replayErr := a.recoverDesktopSessionOperations(ctx)
163 sources, legacySources := a.desktopHistoricalRoots()
164 joined := replayErr
165 conversions, handledStores, conversionErr := discoverDesktopMigrationConversions(ctx, sources)
166 joined = errors.Join(joined, conversionErr)
167 for _, source := range legacySources {
168 source.conversions, source.handledStores = conversions, handledStores
169 joined = errors.Join(joined, markDesktopMigrationPairedStores(source, sources))
170 if err := a.migrateLegacyDirectory(ctx, source); err != nil {
171 joined = errors.Join(joined, err)
172 }
173 }
174 joined = errors.Join(joined, a.migrateStoredConversionLineages(ctx, conversions, sources, handledStores))
175 for _, source := range sources {
176 source.handledStores = handledStores
177 if err := a.migrateCanonicalStore(ctx, *source); err != nil {
178 joined = errors.Join(joined, err)
179 }
180 }
181 return errors.Join(joined, a.discoverHistoricalTrash(ctx), a.reconcileUnregisteredSessions(ctx))
182 }
183
184 // Enumerate trusted storage roots without reading or converting transcripts.
185 func (a *App) desktopHistoricalRoots() (map[string]*desktopMigrationSource, map[string]desktopMigrationSource) {
186 tabs := loadTabsFile()
187 projects := loadProjectsFile()
188 sources := map[string]*desktopMigrationSource{}
189 add := func(scope, workspaceRoot, root string) *desktopMigrationSource {
190 root = filepath.Clean(strings.TrimSpace(root))
191 if root == "." || root == "" || sameDesktopPath(root, a.desktopSessions.root) {
192 return nil
193 }
194 key := canonicalRuntimeRoot(root)
195 if current := sources[key]; current != nil {
196 return current
197 }
198 source := &desktopMigrationSource{root: root, scope: scope, workspaceRoot: workspaceRoot, exact: map[string]bool{}}
199 sources[key] = source
200 return source
201 }
202 addStores := func(scope, workspaceRoot, root string) {
203 if strings.TrimSpace(root) == "" {
204 return
205 }
206 for _, candidate := range desktopLegacyStoreRoots(root) {
207 add(scope, workspaceRoot, candidate)
208 }
209 }
210 addStores("global", "", config.SessionStoreDir())
211 addStores("global", "", config.ProjectSessionStoreDir(globalWorkspaceRoot()))
212 for _, project := range projects.Projects {
213 addStores("project", project.Root, config.ProjectSessionStoreDir(project.Root))
214 }
215 for _, tab := range tabs.Tabs {
216 if strings.TrimSpace(tab.SessionID) == "" {
217 continue
218 }
219 root := config.ProjectSessionStoreDir(globalWorkspaceRoot())
220 if tab.Scope == "project" {
221 root = config.ProjectSessionStoreDir(tab.WorkspaceRoot)
222 }
223 if source := add(tab.Scope, tab.WorkspaceRoot, root); source != nil {
224 source.exact[tab.SessionID] = true
225 }
226 addStores(tab.Scope, tab.WorkspaceRoot, root)
227 }
228 legacySources := map[string]desktopMigrationSource{}
229 addLegacy := func(scope, workspaceRoot, dir string) {
230 dir = filepath.Clean(strings.TrimSpace(dir))
231 if dir == "." || dir == "" {
232 return
233 }
234 key := canonicalRuntimeRoot(dir)
235 if _, ok := legacySources[key]; !ok {
236 legacySources[key] = desktopMigrationSource{root: dir, scope: scope, workspaceRoot: workspaceRoot, exact: map[string]bool{}, pairedRoot: filepath.Join(filepath.Dir(dir), "sessions-v4")}
237 }
238 }
239 addLegacy("global", "", config.SessionDir())
240 addLegacy("global", "", desktopSessionDir(globalWorkspaceRoot()))
241 for _, project := range projects.Projects {
242 addLegacy("project", project.Root, desktopSessionDir(project.Root))
243 }
244 for _, tab := range tabs.Tabs {
245 path := filepath.Clean(strings.TrimSpace(tab.SessionPath))
246 if path == "." || path == "" {
247 continue
248 }
249 dir := filepath.Dir(path)
250 key := canonicalRuntimeRoot(dir)
251 source, ok := legacySources[key]
252 if !ok {
253 source = desktopMigrationSource{root: dir, scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, exact: map[string]bool{}, pairedRoot: filepath.Join(filepath.Dir(dir), "sessions-v4")}
254 }
255 source.exact[path] = true
256 legacySources[key] = source
257 }
258 // Include retired roots discovered only through saved legacy tabs before
259 // indexing conversion provenance.
260 for _, source := range legacySources {
261 addStores(source.scope, source.workspaceRoot, source.pairedRoot)
262 }
263 return sources, legacySources
264 }
265
266 // A paired checkpoint and event store are one migration decision. Never
267 // publish the sidecar independently after a conflict or source failure.
268 func markDesktopMigrationPairedStores(source desktopMigrationSource, sources map[string]*desktopMigrationSource) error {
269 entries, err := os.ReadDir(source.root)
270 if os.IsNotExist(err) {
271 return nil
272 }
273 if err != nil {
274 return err
275 }
276 var joined error
277 for _, entry := range entries {
278 if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) || strings.HasPrefix(entry.Name(), ".") {
279 continue
280 }
281 path := filepath.Join(source.root, entry.Name())
282 pairedRoot, err := desktopLegacyPairedRoot(path, source.pairedRoot)
283 if err != nil {
284 joined = errors.Join(joined, err)
285 continue
286 }
287 if paired := sources[canonicalRuntimeRoot(pairedRoot)]; paired != nil {
288 if paired.pairedIDs == nil {
289 paired.pairedIDs = map[string]bool{}
290 }
291 paired.pairedIDs[agent.BranchID(path)] = true
292 }
293 }
294 return joined
295 }
296
297 func (a *App) migrateCanonicalStore(ctx context.Context, source desktopMigrationSource) (retErr error) {
298 if _, err := os.Stat(source.root); os.IsNotExist(err) {
299 return nil
300 } else if err != nil {
301 return err
302 }
303 persistence := session.NewFilesystemPersistence(source.root)
304 old, err := session.NewService("migration-source", persistence)
305 if err != nil {
306 return err
307 }
308 defer func() { retErr = errors.Join(retErr, old.Shutdown(context.Background())) }()
309 ledger, err := readDesktopMigrationLedger()
310 if err != nil {
311 return err
312 }
313 source.records = ledger.Records
314 var joined error
315 var cursor string
316 for {
317 // A disposable catalog cache cannot decide whether durable history
318 // exists. Enumerate every identity, including entries with failed or
319 // missing metadata, without scheduling writes to the source cache.
320 page, err := persistence.List(ctx, cursor, 100)
321 if err != nil {
322 return errors.Join(joined, err)
323 }
324 for _, info := range page.Sessions {
325 if ctx.Err() != nil {
326 return errors.Join(joined, ctx.Err())
327 }
328 if source.pairedIDs[info.SessionID] || source.handledStores[canonicalRuntimeRoot(filepath.Join(source.root, info.SessionID))] {
329 continue
330 }
331 if info.Codec == session.PrototypeCodec || info.Codec == session.LegacyLinearCodec || info.Codec == session.FinalV31Codec {
332 joined = errors.Join(joined, a.migratePreviewSession(ctx, source, info.SessionID))
333 continue
334 }
335 if err := a.migrateCanonicalSession(ctx, old, source, "", info.SessionID); err != nil {
336 joined = errors.Join(joined, err)
337 }
338 }
339 if page.NextCursor == "" {
340 return joined
341 }
342 if page.NextCursor <= cursor {
343 return errors.Join(joined, errors.New("desktop migration source cursor did not advance"))
344 }
345 cursor = page.NextCursor
346 }
347 }
348
349 func (a *App) migrateCanonicalSession(ctx context.Context, old *session.Service, source desktopMigrationSource, workspaceID, sessionID string) error {
350 preview, err := isDesktopStoredPreview(filepath.Join(source.root, sessionID))
351 if err != nil {
352 return errors.Join(err, updateDesktopMigrationLedger(desktopCanonicalMigrationKey(source.root, sessionID), sessionID, "failed", "source_read"))
353 }
354 if preview {
355 return a.migratePreviewSession(ctx, source, sessionID)
356 }
357 key := desktopCanonicalMigrationKey(source.root, sessionID)
358 if source.versionFingerprint != "" {
359 key += ":review:" + source.versionFingerprint
360 }
361 checkpoint, err := newDesktopMigrationCheckpoint(source, key, canonicalMigrationSourceFiles(source.root, sessionID))
362 if err != nil {
363 return err
364 }
365 if handled, err := a.checkAdoptedMigrationSource(ctx, source, checkpoint); handled || err != nil {
366 return err
367 }
368 if checkpoint.unchanged() {
369 return a.completeRegisteredMigration(ctx, source, checkpoint, checkpoint.record.TargetSessionID, checkpoint.record.ContentDigest)
370 }
371 oldRef := session.SessionRef{HostID: "migration-source", SessionID: sessionID}
372 contentDigest, err := canonicalMigrationDigest(ctx, old.Query(), oldRef)
373 if err != nil {
374 return errors.Join(err, updateDesktopMigrationLedger(key, sessionID, "failed", "source_read"))
375 }
376 if handled, err := a.quarantineChangedMigration(ctx, source, checkpoint, contentDigest); handled || err != nil {
377 return err
378 }
379 if checkpoint.matchesCompletedContent(contentDigest) {
380 return a.completeRegisteredMigration(ctx, source, checkpoint, checkpoint.record.TargetSessionID, contentDigest)
381 }
382 if workspaceID == "" {
383 workspaceID, err = a.ensureDesktopMigrationWorkspace(ctx, source)
384 if err != nil {
385 return err
386 }
387 }
388 target := a.desktopSessionService("")
389 targetID, needsImport, err := a.resolveRegisteredMigrationTarget(ctx, source, checkpoint, sessionID, contentDigest)
390 if err != nil {
391 _ = updateDesktopMigrationLedger(key, sessionID, "failed", "target_conflict", contentDigest)
392 return err
393 }
394 if err := updateDesktopMigrationLedger(key, targetID, "pending", "", contentDigest); err != nil {
395 return err
396 }
397 if err := a.prepareRegisteredMigration(ctx, source, checkpoint, targetID, workspaceID); err != nil {
398 return err
399 }
400 if needsImport {
401 tmp, err := os.MkdirTemp("", "reasonix-session-v5-export-")
402 if err != nil {
403 return err
404 }
405 bundle := filepath.Join(tmp, "bundle")
406 defer os.RemoveAll(tmp)
407 if err := old.TryExportCold(ctx, oldRef, bundle); err != nil {
408 _ = updateDesktopMigrationLedger(key, targetID, "failed", "export", contentDigest)
409 return err
410 }
411 if _, err := target.ImportWithHeader(ctx, bundle, session.CreateOptions{
412 SessionID: targetID, CWD: desktopWorkspaceRoot(source.scope, source.workspaceRoot), Origin: session.SessionOriginCanonicalImport,
413 }); err != nil {
414 _ = updateDesktopMigrationLedger(key, targetID, "failed", "import", contentDigest)
415 return err
416 }
417 }
418
419 return a.completeRegisteredMigration(ctx, source, checkpoint, targetID, contentDigest)
420 }
421
422 func (a *App) migrateLegacyDirectory(ctx context.Context, source desktopMigrationSource) error {
423 entries, err := os.ReadDir(source.root)
424 if os.IsNotExist(err) {
425 return nil
426 }
427 if err != nil {
428 return err
429 }
430 ledger, err := readDesktopMigrationLedger()
431 if err != nil {
432 return err
433 }
434 source.records = ledger.Records
435 var joined error
436 for _, entry := range entries {
437 if ctx.Err() != nil {
438 return ctx.Err()
439 }
440 if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) || strings.HasPrefix(entry.Name(), ".") {
441 continue
442 }
443 path := filepath.Join(source.root, entry.Name())
444 if desktopMigrationAutomaticRecovery(path) {
445 continue
446 }
447 perFile := source
448 perFile.pairedRoot, err = desktopLegacyPairedRoot(path, source.pairedRoot)
449 if err != nil {
450 joined = errors.Join(joined, err, updateDesktopMigrationLedger(desktopLegacyMigrationKey(path), "", "failed", "source_stat"))
451 continue
452 }
453 if err := a.migrateLegacyHeads(ctx, path, perFile); err != nil {
454 joined = errors.Join(joined, err)
455 }
456 }
457 return joined
458 }
459
460 func (a *App) migrateLegacySession(ctx context.Context, path string, source desktopMigrationSource, workspaceID string) (retErr error) {
461 key := desktopLegacyMigrationKey(path)
462 if source.headID != "" {
463 key = desktopLegacyHeadKey(path, source.headID)
464 }
465 return a.migrateLegacyHead(ctx, path, source, workspaceID, source.headID, key, true)
466 }
467
468 func (a *App) migrateLegacyHead(ctx context.Context, path string, source desktopMigrationSource, workspaceID, headID, key string, paired bool) (retErr error) {
469 source.headID = headID
470 if source.operationID == "" {
471 meta, exists, err := agent.LoadBranchMeta(path)
472 if err != nil {
473 return errors.Join(err, a.sourceRecovery(ctx, path, "legacy", "metadata_unreadable", source.scope, source.workspaceRoot, headID))
474 }
475 if exists && meta.WorkspaceRoot != "" && !sameDesktopPath(meta.WorkspaceRoot, desktopWorkspaceRoot(source.scope, source.workspaceRoot)) {
476 return errors.Join(errSessionWorkspaceConflict, a.sourceRecovery(ctx, path, "legacy", "workspace_conflict", source.scope, source.workspaceRoot, headID))
477 }
478 }
479 if source.versionFingerprint != "" {
480 key += ":review:" + source.versionFingerprint
481 }
482 checkpoint, err := newDesktopMigrationCheckpoint(source, key, desktopLegacyMigrationFiles(path, source))
483 if err != nil {
484 return err
485 }
486 if handled, err := a.checkAdoptedMigrationSource(ctx, source, checkpoint); handled || err != nil {
487 return err
488 }
489 if checkpoint.unchanged() {
490 return a.completeRegisteredMigration(ctx, source, checkpoint, checkpoint.record.TargetSessionID, checkpoint.record.ContentDigest)
491 }
492 if len(source.headConversions) > 0 {
493 if err := a.migrateConversionLineage(ctx, path, headID, source, &checkpoint, workspaceID); err != nil {
494 return errors.Join(err, updateDesktopMigrationLedger(key, checkpoint.record.TargetSessionID, "failed", "conversion_import"))
495 }
496 return nil
497 }
498 if paired && source.pairedRoot != "" {
499 // Previous v5 builds may already have adopted the paired canonical
500 // store before discovering its metadata-less legacy checkpoint.
501 pairedKey := desktopCanonicalMigrationKey(source.pairedRoot, agent.BranchID(path))
502 record := source.records[pairedKey]
503 if record.Status == "completed" {
504 source.pairedAdoption = &desktopMigrationReceipt{TargetSessionID: record.TargetSessionID, ContentDigest: record.ContentDigest}
505 } else {
506 source.pairedAdoption = record.PreviousCompletion
507 }
508 }
509 stageRoot, err := os.MkdirTemp("", "reasonix-legacy-import-")
510 if err != nil {
511 return err
512 }
513 defer os.RemoveAll(stageRoot)
514 stage, err := session.NewService("migration-stage", session.NewFilesystemPersistence(filepath.Join(stageRoot, "sessions-v4")))
515 if err != nil {
516 return err
517 }
518 defer func() { retErr = errors.Join(retErr, stage.Shutdown(context.Background())) }()
519 var runtime *session.Runtime
520 if paired && source.pairedRoot != "" {
521 runtime, _, err = stage.ContinueImportedFrom(ctx, path, source.pairedRoot, headID)
522 } else {
523 runtime, _, err = stage.ContinueImported(ctx, path, headID)
524 }
525 if err != nil {
526 _ = updateDesktopMigrationLedger(key, "", "failed", "legacy_import")
527 var diagnostic *session.TranscriptInitializationError
528 if errors.As(err, &diagnostic) {
529 // The source key matches the local migration ledger. Never log the
530 // source path or the unrestricted error string from imported data.
531 slog.Warn("desktop session migration transcript initialization failed", "source_key", key,
532 "stage", "legacy_import", "diagnostic", diagnostic)
533 queueTranscriptInitializationFailure(diagnostic)
534 }
535 return err
536 }
537 if err := stage.Close(ctx, runtime.Ref()); err != nil {
538 return err
539 }
540 return a.publishStagedMigration(ctx, source, checkpoint, stage, runtime.Ref(), workspaceID, session.SessionOriginLegacyImport)
541 }
542
543 func (a *App) publishStagedMigration(ctx context.Context, source desktopMigrationSource, checkpoint desktopMigrationCheckpoint, stage *session.Service, ref session.SessionRef, workspaceID string, origin session.SessionOrigin) error {
544 key := checkpoint.key
545 target := a.desktopSessionService("")
546 contentDigest, err := canonicalMigrationDigest(ctx, stage.Query(), ref)
547 if err != nil {
548 return err
549 }
550 if handled, err := a.quarantineChangedMigration(ctx, source, checkpoint, contentDigest); handled || err != nil {
551 return err
552 }
553 if checkpoint.matchesCompletedContent(contentDigest) {
554 return a.completeRegisteredMigration(ctx, source, checkpoint, checkpoint.record.TargetSessionID, contentDigest)
555 }
556 // An older migrator adopted only the selected DAG head under the path key.
557 // Recognize that receipt when adding explicit head identities, even if the
558 // selected head has since changed and the v5 target has been continued.
559 if source.legacyAdoption != nil && source.legacyAdoption.ContentDigest == contentDigest {
560 return a.completeRegisteredMigration(ctx, source, checkpoint, source.legacyAdoption.TargetSessionID, contentDigest)
561 }
562 if source.pairedAdoption != nil && source.pairedAdoption.ContentDigest == contentDigest {
563 return a.completeRegisteredMigration(ctx, source, checkpoint, source.pairedAdoption.TargetSessionID, contentDigest)
564 }
565 for _, receipt := range source.conversionAdoptions {
566 if receipt.ContentDigest == contentDigest {
567 return a.completeRegisteredMigration(ctx, source, checkpoint, receipt.TargetSessionID, contentDigest)
568 }
569 }
570 if workspaceID == "" {
571 workspaceID, err = a.ensureDesktopMigrationWorkspace(ctx, source)
572 if err != nil {
573 return err
574 }
575 }
576 targetID, needsImport, err := a.resolveRegisteredMigrationTarget(ctx, source, checkpoint, ref.SessionID, contentDigest)
577 if err != nil {
578 _ = updateDesktopMigrationLedger(key, ref.SessionID, "failed", "target_conflict", contentDigest)
579 return err
580 }
581 if err := updateDesktopMigrationLedger(key, targetID, "pending", "", contentDigest); err != nil {
582 return err
583 }
584 if err := a.prepareRegisteredMigration(ctx, source, checkpoint, targetID, workspaceID); err != nil {
585 return err
586 }
587 if needsImport {
588 tmp, err := os.MkdirTemp("", "reasonix-v5-bundle-")
589 if err != nil {
590 return err
591 }
592 defer os.RemoveAll(tmp)
593 bundle := filepath.Join(tmp, "bundle")
594 if err := stage.Export(ctx, ref, bundle); err != nil {
595 return errors.Join(err, updateDesktopMigrationLedger(key, targetID, "failed", "export", contentDigest))
596 }
597 if _, err := target.ImportWithHeader(ctx, bundle, session.CreateOptions{
598 SessionID: targetID, CWD: desktopWorkspaceRoot(source.scope, source.workspaceRoot), Origin: origin,
599 }); err != nil {
600 _ = updateDesktopMigrationLedger(key, targetID, "failed", "import", contentDigest)
601 return err
602 }
603 }
604
605 return a.completeRegisteredMigration(ctx, source, checkpoint, targetID, contentDigest)
606 }
607
608 func canonicalMigrationDigest(ctx context.Context, query *session.Query, ref session.SessionRef) (string, error) {
609 messages, err := query.History(ctx, ref)
610 if err != nil {
611 return "", err
612 }
613 return agent.ContentDigestForMessages(messages)
614 }
615
616 func resolveMigrationTarget(ctx context.Context, query *session.Query, preferredID, sourceKey, contentDigest string, sourcePaths ...string) (string, bool, error) {
617 check := func(sessionID string) (bool, error) {
618 digest, err := canonicalMigrationDigest(ctx, query, session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID})
619 if errors.Is(err, session.ErrSessionNotFound) {
620 return false, nil
621 }
622 if err != nil {
623 return false, err
624 }
625 if digest != contentDigest {
626 return false, nil
627 }
628 if len(sourcePaths) > 0 {
629 info, err := query.Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID})
630 if err != nil {
631 return false, err
632 }
633 body, err := os.ReadFile(filepath.Join(info.Path, "manifest.json"))
634 if err != nil {
635 return false, err
636 }
637 var manifest session.Manifest
638 if err := json.Unmarshal(body, &manifest); err != nil {
639 return false, err
640 }
641 if manifest.Source == nil || !sameDesktopPath(manifest.Source.Path, sourcePaths[0]) {
642 return false, nil
643 }
644 if len(sourcePaths) > 1 && sourcePaths[1] != "" && manifest.Source.LegacyHeadID != sourcePaths[1] {
645 return false, nil
646 }
647 }
648 return true, nil
649 }
650 if identical, err := check(preferredID); err != nil {
651 return "", false, err
652 } else if identical {
653 return preferredID, false, nil
654 } else if _, err := query.Snapshot(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: preferredID}); errors.Is(err, session.ErrSessionNotFound) {
655 return preferredID, true, nil
656 } else if err != nil {
657 return "", false, err
658 }
659 digest := sha256.Sum256([]byte(sourceKey + "\x00" + contentDigest))
660 conflictID := "migr-" + hex.EncodeToString(digest[:12])
661 if identical, err := check(conflictID); err != nil {
662 return "", false, err
663 } else if identical {
664 return conflictID, false, nil
665 } else if _, err := query.Snapshot(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: conflictID}); errors.Is(err, session.ErrSessionNotFound) {
666 return conflictID, true, nil
667 } else if err != nil {
668 return "", false, err
669 }
670 return "", false, errors.New("migration target identity collision")
671 }
672
673 // sourceState optionally supplies the content digest followed by a file revision.
674 func updateDesktopMigrationLedger(sourceKey, targetID, status, errorCode string, sourceState ...string) error {
675 desktopMigrationMu.Lock()
676 defer desktopMigrationMu.Unlock()
677 path := desktopMigrationLedgerPath()
678 release, lockErr := lockDesktopMigrationLedger()
679 if lockErr != nil {
680 return lockErr
681 }
682 defer release()
683 ledger, original, err := readDesktopMigrationLedgerFile()
684 if err != nil {
685 return err
686 }
687 record := ledger.Records[sourceKey]
688 // A failed scan or interrupted update must not erase proof that an older
689 // source revision was already adopted (and may have been continued).
690 if record.Status == "completed" && status != "completed" {
691 record.PreviousCompletion = &desktopMigrationReceipt{
692 TargetSessionID: record.TargetSessionID, ContentDigest: record.ContentDigest, SourceRevision: record.SourceRevision,
693 }
694 }
695 record.SourceKey, record.TargetSessionID, record.Status, record.ErrorCode = sourceKey, targetID, status, errorCode
696 if len(sourceState) > 0 {
697 record.ContentDigest = sourceState[0]
698 }
699 record.SourceRevision = ""
700 if status == "completed" && len(sourceState) > 1 {
701 record.SourceRevision = sourceState[1]
702 }
703 if status == "completed" {
704 record.PreviousCompletion = nil
705 }
706 if status == "pending" {
707 record.Attempts++
708 }
709 ledger.Records[sourceKey] = record
710 body, err := marshalDesktopMigrationRecord(original, ledger, sourceKey)
711 if err != nil {
712 return err
713 }
714 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
715 return err
716 }
717 return fileutil.AtomicWriteFileStrict(path, append(body, '\n'), 0o600)
718 }
719
719 lines GO