返回 DeepSeek-Reasonix
session_preparation.go
根目录 / desktop / session_preparation.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "slices"
8 "sort"
9 "strings"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/session"
13 )
14
15 // SessionPreparationView is the revisioned snapshot shared by navigation and
16 // storage management. Scheduling state intentionally stays outside the durable
17 // import lifecycle ledger.
18 type SessionPreparationView struct {
19 OperationID string `json:"operationId"`
20 SourceKey string `json:"sourceKey"`
21 Status string `json:"status"`
22 Revision uint64 `json:"revision"`
23 Target *session.SessionRef `json:"target,omitempty"`
24 ErrorCode string `json:"errorCode,omitempty"`
25 ErrorDetail string `json:"errorDetail,omitempty"`
26 Retryable bool `json:"retryable"`
27 }
28
29 type HistoricalSourceUpdateView struct {
30 SourceKey string `json:"sourceKey"`
31 Status string `json:"status"`
32 Version string `json:"version,omitempty"`
33 Target *session.SessionRef `json:"target,omitempty"`
34 Source *SessionSourceRef `json:"source,omitempty"`
35 ErrorCode string `json:"errorCode,omitempty"`
36 Retryable bool `json:"retryable"`
37 }
38
39 type historicalSourceUpdateCall struct {
40 view HistoricalSourceUpdateView
41 done chan struct{}
42 delivered bool
43 }
44
45 func preparationSnapshot(call *historicalImportCall) SessionPreparationView {
46 view := SessionPreparationView{OperationID: call.operationID, SourceKey: call.sourceKey, Status: call.status,
47 Revision: call.revision, ErrorCode: call.errorCode, ErrorDetail: call.errorDetail,
48 Retryable: call.status == "blocked" || call.status == "failed" || call.status == "cancelled"}
49 if call.status == "ready" {
50 ref := call.result.Session
51 view.Target = &ref
52 }
53 return view
54 }
55
56 func (a *App) historicalSourceForSelector(selector SessionSelector) (string, historicalSource, error) {
57 if selector.Source != nil {
58 ref := selector.Source
59 if strings.TrimSpace(ref.Path) == "" {
60 return "", historicalSource{}, newSessionOperationError(sessionOperationTargetNotFound, "The source no longer exists.")
61 }
62 id := desktopSourceKey(ref.Path, ref.HeadID)
63 state, loadErr := a.workspaceRegistry().Load(a.bootContext())
64 if loadErr != nil {
65 return "", historicalSource{}, loadErr
66 }
67 if ref.SourceKey != "" && !slices.Contains(state.SourceKeys(ref.SourceKey), id) {
68 return "", historicalSource{}, newSessionOperationError("target_changed", "The source identity changed.")
69 }
70 mapping, adopted, resolveErr := state.ResolveSource(id)
71 if resolveErr != nil {
72 return "", historicalSource{}, resolveErr
73 }
74 format, scope, root := "legacy", "global", ""
75 if info, statErr := os.Stat(ref.Path); statErr == nil && info.IsDir() {
76 format = "canonical"
77 }
78 if adopted {
79 id = mapping.SourceKey
80 workspace := state.Workspaces[mapping.WorkspaceID]
81 root = workspace.Root
82 if mapping.WorkspaceID != "global" {
83 scope = "project"
84 }
85 }
86 if root == "" {
87 for _, project := range loadProjectsFile().Projects {
88 if strings.HasPrefix(cleanDesktopPath(ref.Path), cleanDesktopPath(desktopSessionDir(project.Root))) {
89 scope, root = "project", project.Root
90 break
91 }
92 }
93 }
94 return id, historicalSource{path: ref.Path, head: ref.HeadID, format: format, scope: scope, root: root}, nil
95 }
96 target, err := a.resolveSessionTarget(selector)
97 if err != nil {
98 return "", historicalSource{}, err
99 }
100 if target.Source == nil {
101 state, loadErr := a.workspaceRegistry().Load(a.bootContext())
102 if loadErr != nil {
103 return "", historicalSource{}, loadErr
104 }
105 keys := make([]string, 0)
106 for key, mapping := range state.SourceMappings {
107 if mapping.SessionID == target.SessionRef.SessionID && !strings.Contains(key, ":review:") {
108 keys = append(keys, key)
109 }
110 }
111 sort.Strings(keys)
112 if len(keys) == 0 {
113 return "", historicalSource{}, newSessionOperationError("unsupported", "This session has no historical source.")
114 }
115 mapping := state.SourceMappings[keys[0]]
116 workspace := state.Workspaces[mapping.WorkspaceID]
117 scope := "project"
118 if mapping.WorkspaceID == "global" {
119 scope = "global"
120 }
121 return keys[0], historicalSource{path: mapping.Path, head: mapping.HeadID, format: mapping.Format,
122 scope: scope, root: workspace.Root}, nil
123 }
124 id := strings.TrimSpace(target.Source.SourceKey)
125 if id == "" {
126 id = desktopSourceKey(target.Source.Path, target.Source.HeadID)
127 }
128 format := "legacy"
129 if info, statErr := os.Stat(target.Source.Path); statErr == nil && info.IsDir() {
130 format = "canonical"
131 }
132 return id, historicalSource{path: target.Source.Path, head: target.Source.HeadID, format: format,
133 scope: target.Scope, root: target.WorkspaceRoot}, nil
134 }
135
136 // PrepareSession starts or joins preparation and returns immediately. Canonical
137 // sessions are already ready and never enter the historical queue.
138 func (a *App) PrepareSession(selector SessionSelector) (SessionPreparationView, error) {
139 if selector.Ref != nil {
140 target, err := a.resolveSessionTarget(selector)
141 if err != nil {
142 return SessionPreparationView{}, err
143 }
144 ref := target.SessionRef
145 return SessionPreparationView{OperationID: "ready-" + ref.SessionID, Status: "ready", Revision: target.LifecycleGeneration, Target: &ref}, nil
146 }
147 id, source, err := a.historicalSourceForSelector(selector)
148 if err != nil {
149 return SessionPreparationView{}, err
150 }
151 _, listErr := a.ListHistoricalSessions()
152 c := &a.historicalImports
153 c.mu.Lock()
154 c.initialize(a.bootContext())
155 if _, exists := c.sources[id]; !exists {
156 c.sources[id] = source
157 c.views[id] = historicalImportViewFromSource(id, source)
158 }
159 c.mu.Unlock()
160 if listErr != nil && source.path == "" {
161 return SessionPreparationView{}, listErr
162 }
163 call, err := a.prepareHistoricalSession(id, true, false)
164 if err != nil {
165 return SessionPreparationView{}, err
166 }
167 c.mu.Lock()
168 view := preparationSnapshot(call)
169 c.mu.Unlock()
170 return view, nil
171 }
172
173 func historicalImportViewFromSource(id string, source historicalSource) HistoricalSessionView {
174 return HistoricalSessionView{ID: id, Title: filepathBaseOrFallback(source.path), Format: source.format, Status: "available",
175 Source: &SessionSourceRef{HostID: localDesktopHostID, SourceKey: desktopSourceKey(source.path, source.head), Path: source.path, HeadID: source.head}}
176 }
177
178 func filepathBaseOrFallback(path string) string {
179 path = strings.TrimSpace(path)
180 if path == "" {
181 return "Historical session"
182 }
183 parts := strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' })
184 return parts[len(parts)-1]
185 }
186
187 func (a *App) GetSessionPreparation(operationID string) (SessionPreparationView, error) {
188 c := &a.historicalImports
189 c.mu.Lock()
190 defer c.mu.Unlock()
191 c.initialize(a.bootContext())
192 call := c.operations[strings.TrimSpace(operationID)]
193 if call == nil {
194 return SessionPreparationView{}, newSessionOperationError(sessionOperationTargetNotFound, "The preparation task no longer exists.")
195 }
196 return preparationSnapshot(call), nil
197 }
198
199 func (a *App) CancelSessionPreparation(operationID string) (SessionPreparationView, error) {
200 c := &a.historicalImports
201 c.mu.Lock()
202 defer c.mu.Unlock()
203 call := c.operations[strings.TrimSpace(operationID)]
204 if call == nil {
205 return SessionPreparationView{}, newSessionOperationError(sessionOperationTargetNotFound, "The preparation task no longer exists.")
206 }
207 if call.status == "ready" {
208 return preparationSnapshot(call), nil
209 }
210 call.interactive = false
211 if !call.batch && (call.status == "queued" || call.status == "preparing") {
212 call.cancel()
213 }
214 return preparationSnapshot(call), nil
215 }
216
217 // CheckHistoricalSourceUpdate obtains the same non-blocking ownership used by
218 // conversion. Metadata changes alone are ignored because the durable-content
219 // fingerprint excludes branch presentation sidecars.
220 func (a *App) CheckHistoricalSourceUpdate(selector SessionSelector) (HistoricalSourceUpdateView, error) {
221 id, source, err := a.historicalSourceForSelector(selector)
222 if err != nil {
223 return HistoricalSourceUpdateView{}, err
224 }
225 c := &a.historicalImports
226 c.mu.Lock()
227 c.initialize(a.bootContext())
228 if call := c.updates[id]; call != nil {
229 if call.view.Status != "checking" && call.delivered {
230 delete(c.updates, id)
231 } else {
232 if call.view.Status != "checking" {
233 call.delivered = true
234 }
235 view := call.view
236 c.mu.Unlock()
237 return view, nil
238 }
239 }
240 call := &historicalSourceUpdateCall{view: HistoricalSourceUpdateView{SourceKey: id, Status: "checking"}, done: make(chan struct{})}
241 c.updates[id] = call
242 c.workers.Add(1)
243 ctx := c.ctx
244 initial := call.view
245 c.mu.Unlock()
246 go func() {
247 defer c.workers.Done()
248 var view HistoricalSourceUpdateView
249 select {
250 case c.updateWorker <- struct{}{}:
251 defer func() { <-c.updateWorker }()
252 view = a.checkHistoricalSourceUpdate(ctx, id, source)
253 case <-ctx.Done():
254 view = HistoricalSourceUpdateView{SourceKey: id, Status: "cancelled", Retryable: true}
255 }
256 c.mu.Lock()
257 call.view = view
258 close(call.done)
259 c.mu.Unlock()
260 }()
261 return initial, nil
262 }
263
264 func (a *App) checkHistoricalSourceUpdate(ctx context.Context, id string, source historicalSource) HistoricalSourceUpdateView {
265 state, err := a.workspaceRegistry().Load(ctx)
266 if err != nil {
267 return HistoricalSourceUpdateView{SourceKey: id, Status: "failed", ErrorCode: "registry_unavailable", Retryable: true}
268 }
269 mapping, ok, resolveErr := state.ResolveSource(id)
270 if resolveErr != nil {
271 return HistoricalSourceUpdateView{SourceKey: id, Status: "failed", ErrorCode: "target_changed"}
272 }
273 if !ok {
274 return HistoricalSourceUpdateView{SourceKey: id, Status: "not_prepared", Retryable: true}
275 }
276 release, err := acquireHistoricalSource(ctx, id, source)
277 if err != nil {
278 if historicalSourceBusyError(err) {
279 return HistoricalSourceUpdateView{SourceKey: id, Status: "blocked", ErrorCode: "source_busy", Retryable: true}
280 }
281 return HistoricalSourceUpdateView{SourceKey: id, Status: "failed", ErrorCode: "check_failed", Retryable: true}
282 }
283 defer release()
284 fingerprint, err := desktopSourceFingerprint(source.path)
285 if err != nil {
286 return HistoricalSourceUpdateView{SourceKey: id, Status: "failed", ErrorCode: "source_unavailable", Retryable: true}
287 }
288 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: mapping.SessionID}
289 status := "unchanged"
290 if state.SessionStates[mapping.SessionID].Lifecycle != workspacestate.Active {
291 status = "retired"
292 }
293 if fingerprint != mapping.Fingerprint {
294 status = "available"
295 }
296 sourceRef := SessionSourceRef{HostID: localDesktopHostID, SourceKey: desktopSourceKey(source.path, source.head), Path: source.path, HeadID: source.head}
297 return HistoricalSourceUpdateView{SourceKey: id, Status: status, Version: fingerprint, Target: &ref, Source: &sourceRef}
298 }
299
300 func (a *App) PrepareHistoricalSourceVersion(sourceRef SessionSourceRef, version string) (SessionPreparationView, error) {
301 version = strings.TrimSpace(version)
302 if version == "" {
303 return SessionPreparationView{}, errors.New("historical source version is required")
304 }
305 id, source, err := a.historicalSourceForSelector(SessionSelector{Source: &sourceRef})
306 if err != nil {
307 return SessionPreparationView{}, err
308 }
309 release, err := acquireHistoricalSource(a.bootContext(), id, source)
310 if err != nil {
311 return SessionPreparationView{}, err
312 }
313 current, fingerprintErr := desktopSourceFingerprint(source.path)
314 release()
315 if fingerprintErr != nil {
316 return SessionPreparationView{}, fingerprintErr
317 }
318 if current != version {
319 return SessionPreparationView{}, newSessionOperationError("target_changed", "The historical source changed. Check for updates again.")
320 }
321 versionID := id + ":review:" + version
322 source.version = version
323 c := &a.historicalImports
324 c.mu.Lock()
325 c.initialize(a.bootContext())
326 c.sources[versionID] = source
327 c.views[versionID] = historicalImportViewFromSource(versionID, source)
328 c.mu.Unlock()
329 call, err := a.prepareHistoricalSession(versionID, true, false)
330 if err != nil {
331 return SessionPreparationView{}, err
332 }
333 c.mu.Lock()
334 view := preparationSnapshot(call)
335 c.mu.Unlock()
336 return view, nil
337 }
338
338 lines GO