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