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