返回 DeepSeek-Reasonix
session_target.go
根目录 / desktop / session_target.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "sort"
7 "strings"
8
9 "reasonix/desktop/internal/workspacestate"
10 "reasonix/internal/agent"
11 "reasonix/internal/control"
12 "reasonix/internal/session"
13 "reasonix/internal/sessioncatalog"
14 )
15
16 type SessionOperationMode int
17
18 const (
19 OperationPersistent SessionOperationMode = iota
20 OperationRuntime
21 )
22
23 const (
24 sessionOperationTargetNotFound = "target_not_found"
25 sessionOperationNoMessages = "no_messages"
26 sessionOperationRuntimeNotOpen = "runtime_not_open"
27 sessionOperationRuntimeNotReady = "runtime_not_ready"
28 sessionOperationTitleConflict = "title_conflict"
29 sessionOperationBusy = "operation_busy"
30 sessionOperationFailed = "operation_failed"
31 )
32
33 // SessionOperationError is stable at the host boundary: the code is intended
34 // for frontend localization while the message remains safe for older clients.
35 type SessionOperationError struct {
36 Code string
37 Message string
38 TargetKey string
39 OperationID string
40 Retryable bool
41 }
42
43 func (e *SessionOperationError) Error() string {
44 if e == nil {
45 return ""
46 }
47 return "session_operation:" + e.Code + ":" + e.Message
48 }
49
50 func newSessionOperationError(code, message string) error {
51 retryable := code == sessionOperationRuntimeNotReady || code == sessionOperationTitleConflict ||
52 code == sessionOperationBusy || code == "target_changed" || code == "stale_cursor"
53 return &SessionOperationError{Code: code, Message: message, Retryable: retryable}
54 }
55
56 // RPCErrorData exposes product-safe structured details to the generic host
57 // transport without making hostrpc depend on Desktop application types.
58 func (e *SessionOperationError) RPCErrorData() map[string]any {
59 if e == nil {
60 return nil
61 }
62 data := map[string]any{"sessionCode": e.Code, "retryable": e.Retryable}
63 if e.TargetKey != "" {
64 data["targetKey"] = e.TargetKey
65 }
66 if e.OperationID != "" {
67 data["operationId"] = e.OperationID
68 }
69 return data
70 }
71
72 // SessionSelector is the stable target address accepted by session-level
73 // operations. Ref contains the canonical host-qualified session ID; TopicID is
74 // only the lowest-priority legacy/topic-only compatibility lookup.
75 // Higher-priority fields never fall back when invalid.
76 type SessionSelector struct {
77 Source *SessionSourceRef `json:"source,omitempty"`
78 Ref *session.SessionRef `json:"ref,omitempty"`
79 SessionPath string `json:"sessionPath,omitempty"`
80 TopicID string `json:"topicId,omitempty"`
81 }
82
83 type SessionSourceRef struct {
84 HostID string `json:"hostId"`
85 SourceKey string `json:"sourceKey,omitempty"`
86 Path string `json:"path"`
87 HeadID string `json:"headId,omitempty"`
88 }
89
90 // SessionTarget resolves durable identity independently from runtime state.
91 // Controller is optional and is never used to decide whether the session
92 // exists.
93 type SessionTarget struct {
94 Source *SessionSourceRef
95 TopicID string
96 SessionRef session.SessionRef
97 SessionPath string
98 Scope string
99 WorkspaceRoot string
100 IsOpen bool
101 Ready bool
102 TabID string
103 Controller *control.Controller
104 WorkspaceID string
105 LifecycleGeneration uint64
106 Lifecycle string
107 SharedTopic bool
108 }
109
110 type sessionTargetSelector = SessionSelector
111
112 func (target SessionTarget) key() string {
113 if strings.TrimSpace(target.SessionRef.SessionID) != "" {
114 return "ref:" + target.SessionRef.HostID + ":" + target.SessionRef.SessionID
115 }
116 if target.Source != nil {
117 return "source:" + target.Source.HostID + ":" + target.Source.SourceKey
118 }
119 if path := strings.TrimSpace(target.SessionPath); path != "" {
120 return "path:" + sessionRuntimeKey(path)
121 }
122 return "topic:" + strings.TrimSpace(target.TopicID)
123 }
124
125 func (target SessionTarget) RequireRuntime() (*control.Controller, error) {
126 if !target.IsOpen || target.Controller == nil {
127 return nil, newSessionOperationError(sessionOperationRuntimeNotOpen, "Open this session before using this action.")
128 }
129 if !target.Ready {
130 return nil, newSessionOperationError(sessionOperationRuntimeNotReady, "This session is still loading. Try again shortly.")
131 }
132 return target.Controller, nil
133 }
134
135 func (a *App) resolveSessionTarget(selector sessionTargetSelector) (SessionTarget, error) {
136 return a.resolveSessionTargetWithArchived(selector, false)
137 }
138
139 func (a *App) resolveSessionTargetWithArchived(selector sessionTargetSelector, allowArchived bool) (SessionTarget, error) {
140 if selector.Ref != nil {
141 if strings.TrimSpace(selector.Ref.SessionID) == "" {
142 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
143 }
144 if hostID := strings.TrimSpace(selector.Ref.HostID); hostID != "" && hostID != localDesktopHostID {
145 return SessionTarget{}, newSessionOperationError("unsupported", "This remote session operation is not available from the local session service.")
146 }
147 return a.resolveCanonicalSessionTargetState(*selector.Ref, strings.TrimSpace(selector.TopicID), allowArchived)
148 }
149 if selector.Source != nil {
150 return a.resolveSourceSessionTarget(selector, allowArchived)
151 }
152 if path := strings.TrimSpace(selector.SessionPath); path != "" {
153 if source, err := parseSessionSourceRoute(path); err != nil {
154 return SessionTarget{}, err
155 } else if source != nil {
156 return a.resolveSessionTargetWithArchived(SessionSelector{Source: source, TopicID: selector.TopicID}, allowArchived)
157 }
158 if ref, ok := sessionRefForRoute(a.desktopSessionService(""), path); ok {
159 return a.resolveCanonicalSessionTargetState(ref, strings.TrimSpace(selector.TopicID), allowArchived)
160 }
161 return a.resolveLegacySessionTarget(path, strings.TrimSpace(selector.TopicID), allowArchived)
162 }
163 topicID := strings.TrimSpace(selector.TopicID)
164 if topicID == "" {
165 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
166 }
167 ref, canonical, err := a.canonicalSessionRefForTopic(topicID)
168 if err != nil {
169 return SessionTarget{}, err
170 }
171 scope, root, ok := a.findTopicLocation(topicID)
172 legacyPaths := a.legacySessionPathsForTopic(scope, root, topicID)
173 if canonical {
174 if len(legacyPaths) != 0 {
175 return SessionTarget{}, newSessionOperationError("ambiguous_target", "Select a specific session before using this action.")
176 }
177 return a.resolveCanonicalSessionTargetState(ref, topicID, allowArchived)
178 }
179 if len(legacyPaths) > 1 {
180 return SessionTarget{}, newSessionOperationError("ambiguous_target", "Select a specific session before using this action.")
181 }
182 if len(legacyPaths) == 1 {
183 if rows := expandSessionSourceRows(ProjectNode{SessionPath: legacyPaths[0]}); len(rows) > 1 {
184 return SessionTarget{}, newSessionOperationError("ambiguous_target", "Select a specific historical head before using this action.")
185 }
186 target, resolveErr := a.resolveLegacySessionTarget(legacyPaths[0], topicID, allowArchived)
187 if resolveErr == nil {
188 target.Scope, target.WorkspaceRoot = scope, root
189 }
190 return target, resolveErr
191 }
192 if runtime := a.runtimeSessionTarget(topicID, session.SessionRef{}, ""); runtime.Controller != nil {
193 if runtimeRef, bound := runtime.Controller.SessionRef(); bound {
194 target, resolveErr := a.resolveCanonicalSessionTargetState(runtimeRef, topicID, allowArchived)
195 if resolveErr == nil {
196 return target, nil
197 }
198 }
199 }
200 if !ok {
201 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
202 }
203 if runtime := a.runtimeSessionTarget(topicID, session.SessionRef{}, ""); runtime.IsOpen {
204 runtime.Scope, runtime.WorkspaceRoot = scope, root
205 return runtime, nil
206 }
207 // A newly created topic can exist before its first user turn has allocated
208 // physical session storage. It is a valid empty target, not a missing one.
209 return SessionTarget{TopicID: topicID, Scope: scope, WorkspaceRoot: root}, nil
210 }
211
212 func (a *App) legacySessionPathsForTopic(scope, workspaceRoot, topicID string) []string {
213 topicID = strings.TrimSpace(topicID)
214 if topicID == "" {
215 return nil
216 }
217 mapped := map[string]bool{}
218 if state, err := a.workspaceRegistry().Load(a.bootContext()); err == nil {
219 for _, mapping := range state.SourceMappings {
220 if path := strings.TrimSpace(mapping.Path); path != "" {
221 mapped[sessionRuntimeKey(path)] = true
222 }
223 }
224 }
225 paths := map[string]string{}
226 add := func(path string) {
227 path = strings.TrimSpace(path)
228 if path == "" {
229 return
230 }
231 if _, canonical := parseSessionRoute(path); canonical {
232 return
233 }
234 key := sessionRuntimeKey(path)
235 if key == "" || mapped[key] || agent.IsCleanupPending(path) {
236 return
237 }
238 paths[key] = path
239 }
240 if scope != "" {
241 if catalog := a.sessionCatalog.Load(); catalog != nil {
242 topic, found, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
243 Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID,
244 })
245 if err == nil && found {
246 for _, record := range topic.Sessions {
247 add(record.Path)
248 }
249 }
250 }
251 }
252 for _, dir := range a.knownSessionDirs() {
253 for _, match := range topicSessionMatches(dir, topicID) {
254 add(match.path)
255 }
256 }
257 out := make([]string, 0, len(paths))
258 for _, path := range paths {
259 out = append(out, path)
260 }
261 sort.Strings(out)
262 return out
263 }
264
265 func (a *App) canonicalSessionRefForTopic(topicID string) (session.SessionRef, bool, error) {
266 state, err := a.workspaceRegistry().Load(a.bootContext())
267 if err != nil {
268 return session.SessionRef{}, false, err
269 }
270 var found session.SessionRef
271 for _, workspace := range state.Workspaces {
272 for _, id := range workspace.SessionIDs {
273 presentation := state.Presentation[id]
274 if presentation.TopicID == topicID || "canonical-"+id == topicID || id == topicID || sessionRoute(id) == topicID {
275 if found.SessionID != "" && found.SessionID != id {
276 return session.SessionRef{}, false, newSessionOperationError("ambiguous_target", "Select a specific session before renaming it.")
277 }
278 found = session.SessionRef{HostID: localDesktopHostID, SessionID: id}
279 }
280 }
281 }
282 return found, found.SessionID != "", nil
283 }
284
285 func (a *App) resolveCanonicalSessionTarget(ref session.SessionRef, topicID string) (SessionTarget, error) {
286 return a.resolveCanonicalSessionTargetState(ref, topicID, false)
287 }
288
289 func (a *App) resolveCanonicalSessionTargetState(ref session.SessionRef, topicID string, allowArchived bool) (SessionTarget, error) {
290 if err := validateLocalSessionRef(ref); err != nil {
291 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
292 }
293 if _, err := a.desktopSessionService("").Query().Stat(a.bootContext(), ref); err != nil {
294 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
295 }
296 target := a.runtimeSessionTarget(topicID, ref, sessionRoute(ref.SessionID))
297 target.SessionRef = ref
298 target.SessionPath = sessionRoute(ref.SessionID)
299 if target.TopicID == "" {
300 target.TopicID = topicID
301 }
302 state, loadErr := a.workspaceRegistry().Load(a.bootContext())
303 if loadErr != nil {
304 return SessionTarget{}, loadErr
305 }
306 status, registered := state.SessionStates[ref.SessionID]
307 if !registered || status.Lifecycle == workspacestate.Deleted {
308 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
309 }
310 if status.Lifecycle != workspacestate.Active && !allowArchived {
311 return SessionTarget{}, newSessionOperationError("archived", "Restore this session before renaming it.")
312 }
313 target.LifecycleGeneration = status.Generation
314 target.Lifecycle = status.Lifecycle
315 // Presentation belongs to this exact session, never a caller's stale topic.
316 target.TopicID = state.Presentation[ref.SessionID].TopicID
317 for id, presentation := range state.Presentation {
318 if id != ref.SessionID && target.TopicID != "" && presentation.TopicID == target.TopicID && state.SessionStates[id].Lifecycle == workspacestate.Active {
319 target.SharedTopic = true
320 }
321 }
322 if loadErr == nil {
323 for _, workspace := range state.Workspaces {
324 for _, id := range workspace.SessionIDs {
325 if id != ref.SessionID {
326 continue
327 }
328 target.WorkspaceRoot = workspace.Root
329 target.WorkspaceID = workspace.ID
330 target.Scope = "project"
331 if workspace.ID == "global" {
332 target.Scope, target.WorkspaceRoot = "global", ""
333 }
334 if target.TopicID == "" {
335 target.TopicID = state.Presentation[id].TopicID
336 }
337 }
338 }
339 }
340 if target.TopicID == "" {
341 target.TopicID = "canonical-" + ref.SessionID
342 }
343 return target, nil
344 }
345
346 func (a *App) resolveLegacySessionTarget(path, topicID string, allowArchived bool) (SessionTarget, error) {
347 dir, validated, err := a.sessionDirForPath(path)
348 if err != nil {
349 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
350 }
351 if _, _, err := validateSessionPath(dir, validated); err != nil {
352 return SessionTarget{}, newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
353 }
354 if ref, adopted, adoptionErr := a.legacyCanonicalRef(a.bootContext(), validated); adoptionErr != nil {
355 return SessionTarget{}, newSessionOperationError("target_changed", "The session location or identity changed. Reload it and try again.")
356 } else if adopted {
357 return a.resolveCanonicalSessionTargetState(ref, topicID, allowArchived)
358 }
359 target := a.runtimeSessionTarget(topicID, session.SessionRef{}, validated)
360 target.SessionPath = validated
361 target.TopicID = topicID
362 if meta, ok, err := agent.LoadBranchMeta(validated); err == nil && ok {
363 target.TopicID, target.Scope, target.WorkspaceRoot = meta.TopicID, meta.Scope, meta.WorkspaceRoot
364 }
365 return target, nil
366 }
367
368 func (a *App) runtimeSessionTarget(topicID string, ref session.SessionRef, path string) SessionTarget {
369 a.mu.RLock()
370 defer a.mu.RUnlock()
371 for _, tab := range a.runtimeTabsLocked() {
372 if tab == nil {
373 continue
374 }
375 ctrl, _ := tab.Ctrl.(*control.Controller)
376 matches := false
377 switch {
378 case ref.SessionID != "":
379 if ctrl != nil {
380 bound, ok := ctrl.SessionRef()
381 matches = ok && bound == ref
382 }
383 case path != "":
384 matches = sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(path)
385 default:
386 matches = topicID != "" && (tab.TopicID == topicID || tab.SessionID == topicID || sessionRoute(tab.SessionID) == topicID)
387 }
388 if !matches {
389 continue
390 }
391 resolvedPath := path
392 if resolvedPath == "" {
393 resolvedPath = tab.currentSessionPath()
394 }
395 resolvedRef := ref
396 if resolvedRef.SessionID == "" && ctrl != nil {
397 if bound, ok := ctrl.SessionRef(); ok {
398 resolvedRef = bound
399 }
400 }
401 return SessionTarget{
402 TopicID: topicID, SessionRef: resolvedRef, SessionPath: resolvedPath,
403 Scope: tab.Scope, WorkspaceRoot: tab.WorkspaceRoot,
404 IsOpen: true, Ready: tab.Ready, TabID: tab.ID, Controller: ctrl,
405 }
406 }
407 return SessionTarget{TopicID: topicID, SessionRef: ref, SessionPath: path}
408 }
409
410 // sessionTargetRuntimeRebound detects reuse of the same controller for another
411 // durable identity. Merely switching or closing the tab is not a conflict for
412 // a persistent operation; storage CAS remains authoritative in that case.
413 func (a *App) sessionTargetRuntimeRebound(target SessionTarget) bool {
414 if !target.IsOpen || target.Controller == nil || target.TabID == "" {
415 return false
416 }
417 a.mu.RLock()
418 defer a.mu.RUnlock()
419 tab := a.tabs[target.TabID]
420 if tab == nil || tab.Ctrl != target.Controller {
421 return false
422 }
423 if target.SessionRef.SessionID != "" {
424 ref, ok := target.Controller.SessionRef()
425 return !ok || ref != target.SessionRef
426 }
427 return sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(target.SessionPath)
428 }
429
430 func sessionOperationConflict(err error) error {
431 if errors.Is(err, workspacestate.ErrSessionNotFound) {
432 return newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists or has been archived.")
433 }
434 if errors.Is(err, workspacestate.ErrMutationConflict) {
435 return newSessionOperationError(sessionOperationTitleConflict, "The session changed while AI rename was running. Try again.")
436 }
437 if errors.Is(err, session.ErrSessionTitleChanged) {
438 return newSessionOperationError(sessionOperationTitleConflict, "The session title changed while AI rename was running. Try again.")
439 }
440 return fmt.Errorf("AI rename session: %w", err)
441 }
442
442 lines GO