| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "slices" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | "reasonix/internal/fileutil" |
| 14 | "reasonix/internal/session" |
| 15 | ) |
| 16 | |
| 17 | func desktopLegacySourceFiles(path, pairedRoot string) []string { |
| 18 | files := legacyMigrationSourceFiles(path) |
| 19 | for _, root := range desktopLegacyStoreRoots(pairedRoot) { |
| 20 | files = append(files, canonicalMigrationSourceFiles(root, agent.BranchID(path))...) |
| 21 | } |
| 22 | return files |
| 23 | } |
| 24 | |
| 25 | // Before the identity cutover, SessionDirV3 / ProjectSessionDirV3 used this |
| 26 | // sibling directory. Its presence is independent of the current v4 root. |
| 27 | func desktopLegacyStoreRoots(root string) []string { |
| 28 | if root == "" { |
| 29 | return nil |
| 30 | } |
| 31 | parent := filepath.Dir(root) |
| 32 | return []string{filepath.Join(parent, "sessions-v4"), filepath.Join(parent, "sessions-v3")} |
| 33 | } |
| 34 | |
| 35 | func desktopLegacyPairedRoot(path, root string) (string, error) { |
| 36 | for _, candidate := range desktopLegacyStoreRoots(root) { |
| 37 | if _, err := os.Stat(filepath.Join(candidate, agent.BranchID(path))); err == nil { |
| 38 | return candidate, nil |
| 39 | } else if !os.IsNotExist(err) { |
| 40 | return "", err |
| 41 | } |
| 42 | } |
| 43 | return root, nil |
| 44 | } |
| 45 | |
| 46 | func desktopLegacyHeadKey(path, head string) string { |
| 47 | digest := sha256.Sum256([]byte(canonicalRuntimeRoot(path) + "\x00head\x00" + head)) |
| 48 | return hex.EncodeToString(digest[:]) |
| 49 | } |
| 50 | |
| 51 | // The cached head list is certified by the same source revision as imports. |
| 52 | // Completed DAGs therefore need only stat calls on later startups. The first |
| 53 | // head keeps the historical path key even when the old app changes selection. |
| 54 | func (a *App) migrateLegacyHeads(ctx context.Context, path string, source desktopMigrationSource) error { |
| 55 | key := desktopLegacyMigrationKey(path) |
| 56 | files := desktopLegacyMigrationFiles(path, source) |
| 57 | cp, err := newDesktopMigrationCheckpoint(source, key, files) |
| 58 | if err != nil { |
| 59 | return err |
| 60 | } |
| 61 | record := source.records[key] |
| 62 | if len(record.LegacyHeads) == 0 || record.LegacyHeadsRevision != cp.revision { |
| 63 | heads, err := session.LegacyMigrationHeads(ctx, path) |
| 64 | if err != nil { |
| 65 | return errors.Join(err, updateDesktopMigrationLedger(key, record.TargetSessionID, "failed", "source_heads")) |
| 66 | } |
| 67 | ids := []string{} |
| 68 | selected := "" |
| 69 | for _, head := range heads { |
| 70 | if head.Retired { |
| 71 | continue |
| 72 | } |
| 73 | ids = append(ids, head.ID) |
| 74 | if head.Selected { |
| 75 | selected = head.ID |
| 76 | } |
| 77 | } |
| 78 | if len(heads) == 0 { |
| 79 | ids = []string{""} |
| 80 | } |
| 81 | if len(ids) == 0 { |
| 82 | return errors.New("legacy DAG has no live heads") |
| 83 | } |
| 84 | primary := selected |
| 85 | if len(record.LegacyHeads) > 0 && slices.Contains(ids, record.LegacyHeads[0]) { |
| 86 | primary = record.LegacyHeads[0] |
| 87 | } |
| 88 | if index := slices.Index(ids, primary); index > 0 { |
| 89 | ids[0], ids[index] = ids[index], ids[0] |
| 90 | } |
| 91 | conversions, err := resolveDesktopConversionHeads(ctx, path, source.conversions[canonicalRuntimeRoot(path)]) |
| 92 | if err != nil { |
| 93 | return errors.Join(err, updateDesktopMigrationLedger(key, record.TargetSessionID, "failed", "conversion_heads")) |
| 94 | } |
| 95 | if revision, err := desktopMigrationSourceRevision(files); err != nil || revision != cp.revision { |
| 96 | return errors.Join(errors.New("legacy source changed while listing heads"), err, |
| 97 | updateDesktopMigrationLedger(key, record.TargetSessionID, "failed", "source_changed")) |
| 98 | } |
| 99 | record, err = saveDesktopMigrationHeads(key, ids, selected, cp.revision, conversions) |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | } |
| 104 | source.legacyAdoption = record.LegacyAdoption |
| 105 | var joined error |
| 106 | for _, head := range record.LegacyHeads { |
| 107 | if err := ctx.Err(); err != nil { |
| 108 | return errors.Join(joined, err) |
| 109 | } |
| 110 | headKey := key |
| 111 | if head != record.LegacyPrimaryHead { |
| 112 | headKey = desktopLegacyHeadKey(path, head) |
| 113 | } |
| 114 | perHead := source |
| 115 | for _, converted := range record.LegacyConversions { |
| 116 | if converted.HeadID != head { |
| 117 | continue |
| 118 | } |
| 119 | perHead.headConversions = append(perHead.headConversions, converted) |
| 120 | if source.handledStores != nil { |
| 121 | source.handledStores[canonicalRuntimeRoot(filepath.Join(converted.Root, converted.SessionID))] = true |
| 122 | } |
| 123 | } |
| 124 | if len(perHead.headConversions) > 0 && head == record.LegacySelectedHead && source.pairedRoot != "" { |
| 125 | if err := perHead.includePairedConversion(path, head, headKey); err != nil { |
| 126 | joined = errors.Join(joined, err) |
| 127 | continue |
| 128 | } |
| 129 | } |
| 130 | joined = errors.Join(joined, a.migrateLegacyHead(ctx, path, perHead, "", head, headKey, head == record.LegacySelectedHead)) |
| 131 | } |
| 132 | return joined |
| 133 | } |
| 134 | |
| 135 | // The identity cutover can leave a current paired store without a Source field |
| 136 | // alongside provenance-linked conversions. Include it before suppressing its scan. |
| 137 | func (source *desktopMigrationSource) includePairedConversion(path, head, key string) error { |
| 138 | id := agent.BranchID(path) |
| 139 | dir := filepath.Join(source.pairedRoot, id) |
| 140 | manifest, err := readDesktopMigrationManifest(dir) |
| 141 | if os.IsNotExist(err) { |
| 142 | return nil |
| 143 | } |
| 144 | if err != nil { |
| 145 | return errors.Join(err, updateDesktopMigrationLedger(key, "", "failed", "paired_read")) |
| 146 | } |
| 147 | if manifest.SessionID != id { |
| 148 | return errors.Join(session.ErrDamagedStore, updateDesktopMigrationLedger(key, "", "failed", "paired_identity")) |
| 149 | } |
| 150 | for _, candidate := range source.headConversions { |
| 151 | if sameDesktopPath(filepath.Join(candidate.Root, candidate.SessionID), dir) { |
| 152 | return nil |
| 153 | } |
| 154 | } |
| 155 | source.headConversions = append([]desktopMigrationConversion{{Root: source.pairedRoot, SessionID: id, HeadID: head, Codec: manifest.Codec}}, source.headConversions...) |
| 156 | return nil |
| 157 | } |
| 158 | |
| 159 | func saveDesktopMigrationHeads(key string, heads []string, selected, revision string, conversions []desktopMigrationConversion) (desktopMigrationRecord, error) { |
| 160 | desktopMigrationMu.Lock() |
| 161 | defer desktopMigrationMu.Unlock() |
| 162 | release, lockErr := lockDesktopMigrationLedger() |
| 163 | if lockErr != nil { |
| 164 | return desktopMigrationRecord{}, lockErr |
| 165 | } |
| 166 | defer release() |
| 167 | ledger, original, err := readDesktopMigrationLedgerFile() |
| 168 | if err != nil { |
| 169 | return desktopMigrationRecord{}, err |
| 170 | } |
| 171 | record := ledger.Records[key] |
| 172 | if record.LegacyPrimaryHead == "" && len(heads) > 0 { |
| 173 | record.LegacyPrimaryHead = heads[0] |
| 174 | } |
| 175 | if len(record.LegacyHeads) == 0 && record.LegacyAdoption == nil { |
| 176 | if record.Status == "completed" { |
| 177 | record.LegacyAdoption = &desktopMigrationReceipt{TargetSessionID: record.TargetSessionID, ContentDigest: record.ContentDigest, SourceRevision: record.SourceRevision} |
| 178 | } else if record.PreviousCompletion != nil { |
| 179 | receipt := *record.PreviousCompletion |
| 180 | record.LegacyAdoption = &receipt |
| 181 | } |
| 182 | } |
| 183 | record.SourceKey = key |
| 184 | record.LegacyHeads, record.LegacySelectedHead, record.LegacyHeadsRevision = heads, selected, revision |
| 185 | for i := range conversions { |
| 186 | for _, previous := range record.LegacyConversions { |
| 187 | if previous.Root == conversions[i].Root && previous.SessionID == conversions[i].SessionID && previous.HeadID == conversions[i].HeadID { |
| 188 | conversions[i].extra = previous.extra |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | record.LegacyConversions = conversions |
| 193 | ledger.Records[key] = record |
| 194 | body, err := marshalDesktopMigrationRecord(original, ledger, key) |
| 195 | if err != nil { |
| 196 | return record, err |
| 197 | } |
| 198 | if err := os.MkdirAll(filepath.Dir(desktopMigrationLedgerPath()), 0o700); err != nil { |
| 199 | return record, err |
| 200 | } |
| 201 | return record, fileutil.AtomicWriteFileStrict(desktopMigrationLedgerPath(), append(body, '\n'), 0o600) |
| 202 | } |
| 203 | |
| 204 | func (a *App) migratePreviewSession(ctx context.Context, source desktopMigrationSource, id string) (retErr error) { |
| 205 | key := desktopCanonicalMigrationKey(source.root, id) |
| 206 | if source.versionFingerprint != "" { |
| 207 | key += ":review:" + source.versionFingerprint |
| 208 | } |
| 209 | cp, err := newDesktopMigrationCheckpoint(source, key, canonicalMigrationSourceFiles(source.root, id)) |
| 210 | if err != nil { |
| 211 | return err |
| 212 | } |
| 213 | if handled, err := a.checkAdoptedMigrationSource(ctx, source, cp); handled || err != nil { |
| 214 | return err |
| 215 | } |
| 216 | if cp.unchanged() { |
| 217 | return a.completeRegisteredMigration(ctx, source, cp, cp.record.TargetSessionID, cp.record.ContentDigest) |
| 218 | } |
| 219 | stage, ref, cleanup, err := stageDesktopStoredPreview(ctx, filepath.Join(source.root, id)) |
| 220 | if err != nil { |
| 221 | return errors.Join(err, updateDesktopMigrationLedger(key, id, "failed", "preview_import")) |
| 222 | } |
| 223 | defer cleanup() |
| 224 | return a.publishStagedMigration(ctx, source, cp, stage, ref, "", session.SessionOriginCanonicalImport) |
| 225 | } |
| 226 |