返回 DeepSeek-Reasonix
session_operations.go
根目录 / desktop / session_operations.go
1 package main
2
3 import (
4 "errors"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/agent"
13 "reasonix/internal/session"
14 )
15
16 // SessionMutationResult is the stable response for an explicitly targeted
17 // persistent session operation. Versions are strings at the RPC boundary so
18 // JavaScript never truncates durable 64-bit sequence identities.
19 type SessionMutationResult struct {
20 TargetKey string `json:"targetKey"`
21 OperationID string `json:"operationId"`
22 Committed bool `json:"committed"`
23 Title string `json:"title,omitempty"`
24 TitleVersion string `json:"titleVersion,omitempty"`
25 LifecycleGeneration uint64 `json:"lifecycleGeneration"`
26 ProjectionPending bool `json:"projectionPending,omitempty"`
27 IdentityAliases []string `json:"identityAliases,omitempty"`
28 }
29
30 // SessionCreationResult reports a durable child created from an explicit
31 // source without implying that Desktop opened or selected it.
32 type SessionCreationResult struct {
33 Ref session.SessionRef `json:"ref"`
34 OperationID string `json:"operationId"`
35 Committed bool `json:"committed"`
36 ProjectionPending bool `json:"projectionPending,omitempty"`
37 }
38
39 // SessionTargetChangeEvent is an incremental projection hint. Durable storage
40 // remains authoritative; consumers that miss or cannot order these events
41 // re-read only the named target.
42 type SessionTargetChangeEvent struct {
43 TargetKey string `json:"targetKey"`
44 OperationID string `json:"operationId,omitempty"`
45 LifecycleGeneration uint64 `json:"lifecycleGeneration"`
46 Title string `json:"title,omitempty"`
47 WorkspaceID string `json:"workspaceId,omitempty"`
48 }
49
50 func (a *App) emitSessionTargetChange(name string, event SessionTargetChangeEvent) {
51 a.emitRuntimeEvent(name, event)
52 }
53
54 func sessionOperationErrorForTarget(err error, targetKey, operationID string) error {
55 if err == nil {
56 return nil
57 }
58 var operationErr *SessionOperationError
59 if errors.As(err, &operationErr) {
60 copy := *operationErr
61 if copy.TargetKey == "" {
62 copy.TargetKey = targetKey
63 }
64 if copy.OperationID == "" {
65 copy.OperationID = operationID
66 }
67 return &copy
68 }
69 switch {
70 case errors.Is(err, workspacestate.ErrMutationConflict):
71 return &SessionOperationError{
72 Code: "target_changed", Message: "The session location or state changed. Reload it and try again.",
73 TargetKey: targetKey, OperationID: operationID, Retryable: true,
74 }
75 case errors.Is(err, workspacestate.ErrSessionNotFound), errors.Is(err, session.ErrSessionNotFound):
76 return &SessionOperationError{
77 Code: sessionOperationTargetNotFound, Message: "The session no longer exists or has been removed.",
78 TargetKey: targetKey, OperationID: operationID,
79 }
80 case errors.Is(err, errTopicArchiveBusy), errors.Is(err, errTopicHasActiveWork), errors.Is(err, agent.ErrSessionLeaseHeld):
81 return &SessionOperationError{
82 Code: sessionOperationBusy, Message: "Another operation is using this session. Try again shortly.",
83 TargetKey: targetKey, OperationID: operationID, Retryable: true,
84 }
85 }
86 // The host log is the only place that still carries the cause.
87 slog.Warn("desktop: unclassified session operation failure", "target", targetKey, "operation", operationID, "err", err)
88 // RPC messages are user-visible. Do not pass through paths, lease holder
89 // details, provider bodies, or credential-adjacent diagnostics from an
90 // unclassified lower-level error.
91 return &SessionOperationError{
92 Code: sessionOperationFailed, Message: "Unable to complete this session operation.",
93 TargetKey: targetKey, OperationID: operationID,
94 }
95 }
96
97 // RenameSessionTarget performs a manual persistent rename without opening or
98 // selecting the target session.
99 func (a *App) RenameSessionTarget(selector SessionSelector, title string) (SessionMutationResult, error) {
100 target, err := a.resolveSessionTarget(selector)
101 if err != nil {
102 return SessionMutationResult{}, err
103 }
104 key := target.key()
105 operationID := "title-manual-" + strings.TrimPrefix(newTabID(), "tab_")
106 a.cancelAISessionTitle(key)
107 title = strings.TrimSpace(title)
108 if target.SessionRef.SessionID != "" {
109 err = a.renameCanonicalSessionTarget(target, title)
110 if err == nil {
111 info, statErr := a.desktopSessionService("").Query().Stat(a.bootContext(), target.SessionRef)
112 if statErr != nil {
113 result := SessionMutationResult{
114 TargetKey: key, OperationID: operationID, Committed: true, Title: title,
115 LifecycleGeneration: target.LifecycleGeneration, ProjectionPending: true,
116 }
117 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
118 TargetKey: key, OperationID: operationID, LifecycleGeneration: target.LifecycleGeneration,
119 Title: title, WorkspaceID: target.WorkspaceID,
120 })
121 return result, nil
122 }
123 result := SessionMutationResult{
124 TargetKey: key, OperationID: operationID, Committed: true, Title: title,
125 TitleVersion: titleSequenceVersion(info.TitleSequence),
126 LifecycleGeneration: target.LifecycleGeneration,
127 }
128 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
129 TargetKey: key, OperationID: operationID, LifecycleGeneration: target.LifecycleGeneration, Title: title,
130 WorkspaceID: target.WorkspaceID,
131 })
132 return result, nil
133 }
134 } else if target.SessionPath != "" {
135 if target.Source != nil {
136 err = a.saveHistoricalSourcePresentation(target.Source.SourceKey, func(presentation *historicalSourcePresentation) {
137 presentation.Title = title
138 })
139 }
140 if err != nil {
141 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
142 }
143 if info, statErr := os.Stat(target.SessionPath); target.Source != nil && statErr == nil && info.IsDir() {
144 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{TargetKey: key, OperationID: operationID, Title: title})
145 a.emitProjectTreeMetadataChanged()
146 return SessionMutationResult{TargetKey: key, OperationID: operationID, Committed: true, Title: title}, nil
147 }
148 err = a.RenameSession(target.SessionPath, title)
149 if err == nil {
150 _, revision, revisionErr := agent.SessionTitleSnapshot(target.SessionPath)
151 result := SessionMutationResult{
152 TargetKey: key, OperationID: operationID, Committed: true, Title: title,
153 TitleVersion: revision, LifecycleGeneration: target.LifecycleGeneration,
154 ProjectionPending: revisionErr != nil,
155 }
156 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
157 TargetKey: key, OperationID: operationID, LifecycleGeneration: target.LifecycleGeneration, Title: title,
158 })
159 return result, nil
160 }
161 } else if target.TopicID != "" {
162 err = a.RenameTopic(target.TopicID, title)
163 if err == nil {
164 result := SessionMutationResult{
165 TargetKey: key, OperationID: operationID, Committed: true, Title: title,
166 LifecycleGeneration: target.LifecycleGeneration,
167 }
168 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
169 TargetKey: key, OperationID: operationID, LifecycleGeneration: target.LifecycleGeneration, Title: title,
170 })
171 return result, nil
172 }
173 } else {
174 err = newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
175 }
176 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
177 }
178
179 func titleSequenceVersion(sequence uint64) string {
180 if sequence == 0 {
181 return ""
182 }
183 return "event:" + strconv.FormatUint(sequence, 10)
184 }
185
186 // ArchiveSessionTarget archives one explicit durable target. It does not select
187 // the target or create a conversation controller.
188 func (a *App) ArchiveSessionTarget(selector SessionSelector) (SessionMutationResult, error) {
189 target, err := a.resolveSessionMutationTarget(selector)
190 if err != nil {
191 return SessionMutationResult{}, err
192 }
193 key := target.key()
194 operationID := "archive-" + strings.TrimPrefix(newTabID(), "tab_")
195 var archived SessionTarget
196 if target.SessionRef.SessionID != "" {
197 archived, err = a.archiveCanonicalSessionWithOperation(target.SessionRef, operationID)
198 } else if target.SessionPath != "" {
199 archived, err = a.archiveSessionPathWithOperation(target.SessionPath, operationID)
200 } else {
201 err = newSessionOperationError(sessionOperationNoMessages, "This empty session has no durable content to archive.")
202 }
203 if err != nil {
204 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
205 }
206 return SessionMutationResult{
207 TargetKey: key, OperationID: operationID, Committed: true,
208 LifecycleGeneration: archived.LifecycleGeneration,
209 ProjectionPending: archived.LifecycleGeneration == 0,
210 IdentityAliases: a.sessionTargetIdentityAliases(target),
211 }, nil
212 }
213
214 func (a *App) sessionTargetIdentityAliases(target SessionTarget) []string {
215 aliases := []string{projectNodeSessionKey(ProjectNode{SessionPath: target.SessionPath})}
216 if target.SessionRef.SessionID != "" {
217 aliases = append(aliases, projectNodeSessionKey(ProjectNode{Session: &target.SessionRef}))
218 if state, err := a.workspaceRegistry().Load(a.bootContext()); err == nil {
219 aliases = append(aliases, sourceAliases(state, desktopWorkspaceOwnerID(state, target.Scope, target.WorkspaceRoot), target.SessionRef.SessionID)...)
220 }
221 }
222 return aliases
223 }
224
225 // RestoreSessionTarget restores one explicit archived target. Canonical
226 // sessions use the workspace journal; pre-adoption legacy trash entries retain
227 // the existing guarded restore flow.
228 func (a *App) RestoreSessionTarget(selector SessionSelector) (SessionMutationResult, error) {
229 operationID := "restore-" + strings.TrimPrefix(newTabID(), "tab_")
230 target, err := a.resolveSessionTargetWithArchived(selector, true)
231 if err == nil {
232 key := target.key()
233 if target.SessionRef.SessionID != "" {
234 if target.Lifecycle != workspacestate.Archived {
235 err = newSessionOperationError("target_changed", "This session is not archived.")
236 } else {
237 var restored SessionTarget
238 restored, err = a.restoreCanonicalSessionWithOperation(target.SessionRef, operationID)
239 if err == nil {
240 return SessionMutationResult{
241 TargetKey: key, OperationID: operationID, Committed: true,
242 LifecycleGeneration: restored.LifecycleGeneration,
243 ProjectionPending: restored.LifecycleGeneration == 0,
244 }, nil
245 }
246 }
247 } else {
248 err = newSessionOperationError("target_changed", "This session is not archived.")
249 }
250 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
251 }
252
253 // A legacy trash path is intentionally outside the live-session resolver.
254 // Honor selector priority and try this only when sessionPath is the explicit
255 // highest-priority field.
256 if selector.Ref != nil || strings.TrimSpace(selector.SessionPath) == "" {
257 return SessionMutationResult{}, sessionOperationErrorForTarget(err, "", operationID)
258 }
259 trashPath := strings.TrimSpace(selector.SessionPath)
260 dir, trashErr := a.trashedSessionDir(trashPath)
261 if trashErr != nil {
262 return SessionMutationResult{}, sessionOperationErrorForTarget(err, "", operationID)
263 }
264 _, keyName, _, trashErr := validateTrashedSessionPath(dir, trashPath)
265 if trashErr != nil {
266 return SessionMutationResult{}, sessionOperationErrorForTarget(trashErr, "", operationID)
267 }
268 targetKey := (SessionTarget{SessionPath: trashPath}).key()
269 a.cancelAISessionTitle(targetKey)
270 if restoreErr := a.RestoreSession(trashPath); restoreErr != nil {
271 return SessionMutationResult{}, sessionOperationErrorForTarget(restoreErr, targetKey, operationID)
272 }
273 livePath := filepath.Join(dir, keyName)
274 restored, resolveErr := a.resolveSessionTarget(SessionSelector{SessionPath: livePath})
275 if resolveErr != nil {
276 restored = SessionTarget{SessionPath: livePath}
277 }
278 a.emitSessionTargetChange("session_restored", SessionTargetChangeEvent{
279 TargetKey: targetKey, OperationID: operationID,
280 LifecycleGeneration: restored.LifecycleGeneration, WorkspaceID: restored.WorkspaceID,
281 })
282 return SessionMutationResult{
283 TargetKey: targetKey, OperationID: operationID, Committed: true,
284 LifecycleGeneration: restored.LifecycleGeneration,
285 ProjectionPending: resolveErr != nil,
286 }, nil
287 }
288
289 // MoveSessionTarget reorders one explicit session inside its owning workspace.
290 // A legacy source is first adopted through the existing migration journal so
291 // the move operates on a durable canonical identity rather than a path alias.
292 func (a *App) MoveSessionTarget(selector SessionSelector, workspaceID, beforeSessionID string) (SessionMutationResult, error) {
293 target, err := a.resolveSessionMutationTarget(selector)
294 if err != nil {
295 return SessionMutationResult{}, err
296 }
297 key := target.key()
298 operationID := "move-" + strings.TrimPrefix(newTabID(), "tab_")
299 if target.SessionRef.SessionID == "" {
300 scope, root := target.Scope, target.WorkspaceRoot
301 if scope == "" {
302 scope = "global"
303 }
304 adoptionWorkspace, ensureErr := a.ensureDesktopWorkspace(a.bootContext(), scope, root)
305 if ensureErr != nil {
306 return SessionMutationResult{}, sessionOperationErrorForTarget(ensureErr, key, operationID)
307 }
308 if strings.TrimSpace(workspaceID) == "" {
309 workspaceID = adoptionWorkspace
310 }
311 if workspaceID != adoptionWorkspace {
312 err = newSessionOperationError("target_changed", "The session cannot be moved outside its owning workspace.")
313 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
314 }
315 if migrateErr := a.migrateLegacySession(
316 a.bootContext(),
317 target.SessionPath,
318 desktopMigrationSource{scope: scope, workspaceRoot: root},
319 adoptionWorkspace,
320 ); migrateErr != nil {
321 return SessionMutationResult{}, sessionOperationErrorForTarget(migrateErr, key, operationID)
322 }
323 adopted, adoptedErr := a.resolveSessionTarget(SessionSelector{SessionPath: target.SessionPath})
324 if adoptedErr != nil || adopted.SessionRef.SessionID == "" {
325 if adoptedErr == nil {
326 adoptedErr = workspacestate.ErrMutationConflict
327 }
328 return SessionMutationResult{}, sessionOperationErrorForTarget(adoptedErr, key, operationID)
329 }
330 target = adopted
331 }
332 moved, err := a.moveWorkspaceSessionWithOperation(target.SessionRef, workspaceID, beforeSessionID, operationID)
333 if err != nil {
334 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
335 }
336 return SessionMutationResult{
337 TargetKey: key, OperationID: operationID, Committed: true,
338 LifecycleGeneration: moved.LifecycleGeneration,
339 ProjectionPending: moved.LifecycleGeneration == 0,
340 }, nil
341 }
342
343 // DeleteSessionTarget permanently removes one explicit archived canonical
344 // application session. Upgrade-source artifacts retain the existing tombstone
345 // policy and are never silently erased.
346 func (a *App) DeleteSessionTarget(selector SessionSelector) (SessionMutationResult, error) {
347 target, err := a.resolveSessionTargetWithArchived(selector, true)
348 operationID := "delete-" + strings.TrimPrefix(newTabID(), "tab_")
349 if err != nil && selector.Ref != nil {
350 target, err = a.resolveCanonicalPurgeTarget(*selector.Ref)
351 }
352 if err != nil {
353 return SessionMutationResult{}, sessionOperationErrorForTarget(err, "", operationID)
354 }
355 key := target.key()
356 if target.SessionRef.SessionID == "" {
357 err = newSessionOperationError("unsupported", "This historical recovery source cannot be permanently deleted.")
358 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
359 }
360 if target.Lifecycle != workspacestate.Archived && target.Lifecycle != workspacestate.Deleted {
361 err = newSessionOperationError("archived", "Archive this session before deleting it.")
362 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
363 }
364 deleted, err := a.purgeCanonicalSessionWithOperation(target.SessionRef, operationID)
365 if err != nil {
366 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
367 }
368 return SessionMutationResult{
369 TargetKey: key, OperationID: operationID, Committed: true,
370 LifecycleGeneration: deleted.LifecycleGeneration,
371 }, nil
372 }
373
373 lines GO