返回 DeepSeek-Reasonix
historical_conversion_adoption.go
根目录 / desktop / historical_conversion_adoption.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "os"
8 "path/filepath"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/session"
12 "reasonix/internal/store"
13 )
14
15 // Conversion receipts certify adoption of a particular history, not ownership
16 // of every future history written in the same directory. In particular, a
17 // tombstone must consume an identical copy without consuming later work.
18 func canonicalConversionReceipts(ctx context.Context, dir string) ([]desktopMigrationReceipt, error) {
19 ledger, err := readDesktopMigrationLedger()
20 if err != nil {
21 return nil, err
22 }
23 manifest, err := readDesktopMigrationManifest(dir)
24 if err != nil {
25 return nil, err
26 }
27 var receipts []desktopMigrationReceipt
28 add := func(record desktopMigrationRecord) {
29 if record.Status == "completed" && record.TargetSessionID != "" && record.ContentDigest != "" {
30 receipts = append(receipts, desktopMigrationReceipt{TargetSessionID: record.TargetSessionID, ContentDigest: record.ContentDigest})
31 } else if record.PreviousCompletion != nil {
32 receipts = append(receipts, *record.PreviousCompletion)
33 }
34 }
35 baseKey := desktopCanonicalMigrationKey(filepath.Dir(dir), filepath.Base(dir))
36 for key, record := range ledger.Records {
37 if historicalSourceKeyMatches(key, baseKey) {
38 add(record)
39 }
40 }
41 if manifest.Source == nil {
42 return receipts, nil
43 }
44 provenance := manifest.Source
45 // A recorded conversion names this exact directory and its frozen head.
46 // Do not match another conversion merely because both share an origin.
47 for _, record := range ledger.Records {
48 for _, conversion := range record.LegacyConversions {
49 if sameDesktopPath(filepath.Join(conversion.Root, conversion.SessionID), dir) &&
50 (provenance.LegacyHeadID == "" || conversion.HeadID == provenance.LegacyHeadID) {
51 // The path record also inventories conversions of other heads;
52 // its own adoption belongs only to the immutable primary head.
53 if conversion.HeadID == record.LegacyPrimaryHead {
54 add(record)
55 } else if store.IsSessionTranscriptName(filepath.Base(provenance.Path)) {
56 add(ledger.Records[desktopLegacyHeadKey(provenance.Path, conversion.HeadID)])
57 }
58 }
59 }
60 }
61 if store.IsSessionTranscriptName(filepath.Base(provenance.Path)) && (provenance.Version == "" || provenance.Version == "legacy") {
62 head := provenance.LegacyHeadID
63 if head == "" {
64 converted, resolveErr := resolveDesktopConversionHeads(ctx, provenance.Path, []desktopMigrationConversion{{LegacyDir: filepath.Join(dir, "legacy")}})
65 if resolveErr != nil {
66 if len(receipts) != 0 {
67 return receipts, nil
68 }
69 return nil, resolveErr
70 }
71 head = converted[0].HeadID
72 }
73 if head != "" {
74 add(ledger.Records[desktopLegacyHeadKey(provenance.Path, head)])
75 }
76 base := ledger.Records[desktopLegacyMigrationKey(provenance.Path)]
77 if base.LegacyPrimaryHead == head {
78 add(base)
79 }
80 } else {
81 add(ledger.Records[desktopCanonicalMigrationKey(filepath.Dir(provenance.Path), filepath.Base(provenance.Path))])
82 }
83 return receipts, nil
84 }
85
86 func (a *App) reconcileCanonicalConversion(ctx context.Context, source desktopMigrationSource, cp desktopMigrationCheckpoint, digest string) (bool, error) {
87 // Explicit "open a version" requests intentionally create a branch, even
88 // when only metadata changed. Archive deduplication must not change that API.
89 if source.versionFingerprint != "" && !source.deferArchive {
90 return false, nil
91 }
92 path, _ := migrationCheckpointPath(cp)
93 receipts, err := canonicalConversionReceipts(ctx, path)
94 if err != nil {
95 return true, errors.Join(newSessionOperationError("source_unavailable", "The historical source could not be verified. Its files were retained."), err)
96 }
97 target, err := a.selectCanonicalConversionTarget(ctx, source, path, digest, receipts)
98 if err != nil {
99 return true, err
100 }
101 if target == "" {
102 return false, nil
103 }
104 if err := verifyCanonicalConversionClosure(ctx, path); err != nil {
105 return true, errors.Join(newSessionOperationError("source_unavailable", "The historical source could not be verified. Its files were retained."), err)
106 }
107 return true, a.completeRegisteredMigration(ctx, source, cp, target, digest)
108 }
109
110 // Validate the full referenced content closure before consuming a residual
111 // directory. A transcript digest alone does not prove attachment blobs readable.
112 func verifyCanonicalConversionClosure(ctx context.Context, path string) error {
113 tmp, err := os.MkdirTemp("", "reasonix-conversion-proof-")
114 if err != nil {
115 return err
116 }
117 defer os.RemoveAll(tmp)
118 service, err := session.NewService("conversion-proof", session.NewFilesystemPersistence(filepath.Dir(path)))
119 if err != nil {
120 return err
121 }
122 defer shutdownHistoricalProofService(service)
123 return service.TryExportCold(ctx, session.SessionRef{HostID: "conversion-proof", SessionID: filepath.Base(path)}, filepath.Join(tmp, "verified"))
124 }
125
126 func shutdownHistoricalProofService(service *session.Service) {
127 if err := service.Shutdown(context.Background()); err != nil {
128 slog.Warn("desktop: historical proof service shutdown failed", "err", err)
129 }
130 }
131
132 func (a *App) proveRetiredImportOrigin(ctx context.Context, state workspacestate.State, path, head, target string) error {
133 if mapping, found, err := state.ResolveSource(desktopSourceKey(path, head)); err != nil {
134 return err
135 } else if found && mapping.SessionID == target {
136 return nil
137 }
138 if info, err := os.Stat(path); err != nil {
139 return err
140 } else if info.IsDir() {
141 receipts, err := canonicalConversionReceipts(ctx, path)
142 if err != nil {
143 return err
144 }
145 for _, receipt := range receipts {
146 if receipt.TargetSessionID == target && receipt.ContentDigest != "" {
147 return nil
148 }
149 }
150 }
151 return newSessionOperationError("source_ambiguous", "The historical source's relationship to a deleted session could not be verified.")
152 }
153
154 // Preserve the deletion barrier even for callers that use preparation directly.
155 func historicalRetiredError(lifecycle string) error {
156 if lifecycle == workspacestate.Deleted {
157 return session.ErrSessionNotFound
158 }
159 return errors.New("historical session is archived; restore it from the archive")
160 }
161
161 lines GO