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