返回 DeepSeek-Reasonix
session_workspace_api.go
根目录 / desktop / session_workspace_api.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "reasonix/desktop/internal/workspacestate"
12 "reasonix/internal/agent"
13 "reasonix/internal/historywork"
14 "reasonix/internal/session"
15 "strconv"
16 "strings"
17 "time"
18 )
19
20 const localDesktopHostID = "local"
21
22 type WorkspaceSummary struct {
23 ID string `json:"id"`
24 Root string `json:"root"`
25 Title string `json:"title"`
26 SessionIDs []string `json:"sessionIds"`
27 Visible bool `json:"visible"`
28 CreatedAt int64 `json:"createdAt"`
29 UpdatedAt int64 `json:"updatedAt"`
30 }
31
32 type WorkspacePendingCreate struct {
33 OperationID string `json:"operationId"`
34 WorkspaceID string `json:"workspaceId"`
35 SessionID string `json:"sessionId"`
36 CreatedAt int64 `json:"createdAt"`
37 }
38
39 type WorkspaceSnapshot struct {
40 Generation uint64 `json:"generation"`
41 Workspaces []WorkspaceSummary `json:"workspaces"`
42 ArchivedSessionIDs []string `json:"archivedSessionIds"`
43 PendingCreates []WorkspacePendingCreate `json:"pendingCreates"`
44 }
45
46 type WorkspaceSessionPage struct {
47 Sessions []WorkspaceSessionSummary `json:"sessions"`
48 NextCursor string `json:"nextCursor,omitempty"`
49 RegistryGeneration uint64 `json:"registryGeneration"`
50 }
51
52 type SessionArchitectureDiagnostics struct {
53 HistoryMaintenance *historywork.Diagnostics `json:"historyMaintenance,omitempty"`
54 ReadSnapshots *ReadSnapshotDiagnostics `json:"readSnapshots,omitempty"`
55 PendingOperations int `json:"pending_operations"`
56 MissingMembers int `json:"missing_members"`
57 IdentityMismatches int `json:"identity_mismatches"`
58 SourceConflicts int `json:"source_conflicts"`
59 RecoveryEntries int `json:"recovery_entries"`
60 SessionHeadersTotal int `json:"session_headers_total"`
61 WorkspaceMembersTotal int `json:"workspace_members_total"`
62 UnassignedSessions int `json:"unassigned_sessions"`
63 MigrationPending int `json:"migration_pending"`
64 MigrationFailed int `json:"migration_failed"`
65 MigrationCompleted int `json:"migration_completed"`
66 ProjectionPending int `json:"projection_pending"`
67 ProjectionFailed int `json:"projection_failed"`
68 PendingCreateRecovered uint64 `json:"pending_create_recovered"`
69 PruneBlockedPersistence uint64 `json:"prune_blocked_persistence"`
70 }
71
72 func unixMillis(value time.Time) int64 {
73 if value.IsZero() {
74 return 0
75 }
76 return value.UnixMilli()
77 }
78
79 func (a *App) GetWorkspaceSnapshot() (WorkspaceSnapshot, error) {
80 state, err := a.workspaceRegistry().Load(context.Background())
81 if err != nil {
82 return WorkspaceSnapshot{}, err
83 }
84 result := WorkspaceSnapshot{
85 Generation: state.Generation,
86 Workspaces: make([]WorkspaceSummary, 0, len(state.WorkspaceIDs)),
87 ArchivedSessionIDs: append([]string{}, state.ArchivedSessionIDs...),
88 PendingCreates: make([]WorkspacePendingCreate, 0, len(state.PendingCreates)),
89 }
90 for _, id := range state.WorkspaceIDs {
91 workspace, ok := state.Workspaces[id]
92 if !ok {
93 continue
94 }
95 result.Workspaces = append(result.Workspaces, WorkspaceSummary{
96 ID: workspace.ID, Root: workspace.Root, Title: workspace.Title,
97 SessionIDs: append([]string{}, workspace.SessionIDs...), Visible: workspace.Visible,
98 CreatedAt: unixMillis(workspace.CreatedAt), UpdatedAt: unixMillis(workspace.UpdatedAt),
99 })
100 }
101 for _, pending := range state.PendingCreates {
102 result.PendingCreates = append(result.PendingCreates, WorkspacePendingCreate{
103 OperationID: pending.OperationID, WorkspaceID: pending.WorkspaceID,
104 SessionID: pending.SessionID, CreatedAt: unixMillis(pending.CreatedAt),
105 })
106 }
107 return result, nil
108 }
109
110 func (a *App) GetSessionArchitectureDiagnostics() (SessionArchitectureDiagnostics, error) {
111 maintenance := a.historyMaintenance.Diagnostics()
112 state, err := a.workspaceRegistry().Load(context.Background())
113 if err != nil {
114 return SessionArchitectureDiagnostics{}, err
115 }
116 infos, listErr := listAllCanonicalSessionInfo(context.Background(), a.desktopSessionService("").Query())
117 result := SessionArchitectureDiagnostics{
118 HistoryMaintenance: &maintenance,
119 ReadSnapshots: a.desktopSessions.readSnapshots.diagnostics(),
120 PendingCreateRecovered: a.desktopSessions.pendingCreateRecovered.Load(),
121 PruneBlockedPersistence: a.desktopSessions.pruneBlockedPersistence.Load(),
122 }
123 members := map[string]bool{}
124 for _, op := range state.PendingOperations {
125 if op.Phase != "committed" {
126 result.PendingOperations++
127 }
128 }
129 for _, entry := range state.RecoveryEntries {
130 if entry.Status == "restored" {
131 continue
132 }
133 result.RecoveryEntries++
134 if strings.Contains(entry.Reason, "conflict") {
135 result.SourceConflicts++
136 }
137 }
138 for _, workspace := range state.Workspaces {
139 result.WorkspaceMembersTotal += len(workspace.SessionIDs)
140 for _, sessionID := range workspace.SessionIDs {
141 members[sessionID] = true
142 if info, found := infos[sessionID]; !found {
143 result.MissingMembers++
144 } else if !sameDesktopPath(info.CWD, workspace.Root) {
145 result.IdentityMismatches++
146 }
147 }
148 }
149 for sessionID, info := range infos {
150 if info.Origin != "" {
151 result.SessionHeadersTotal++
152 }
153 if !members[sessionID] {
154 result.UnassignedSessions++
155 }
156 switch info.MetadataStatus {
157 case session.MetadataPending:
158 result.ProjectionPending++
159 case session.MetadataFailed:
160 result.ProjectionFailed++
161 }
162 }
163 desktopMigrationMu.Lock()
164 var ledger desktopMigrationLedger
165 body, readErr := os.ReadFile(desktopMigrationLedgerPath())
166 if readErr == nil {
167 readErr = json.Unmarshal(body, &ledger)
168 }
169 desktopMigrationMu.Unlock()
170 if readErr != nil && !os.IsNotExist(readErr) {
171 return result, readErr
172 }
173 for _, record := range ledger.Records {
174 switch record.Status {
175 case "pending":
176 result.MigrationPending++
177 case "failed":
178 result.MigrationFailed++
179 case "completed":
180 result.MigrationCompleted++
181 }
182 }
183 return result, listErr
184 }
185
186 func (a *App) ListWorkspaceSessions(workspaceID, queryText, cursor string, limit int, includeArchived bool) (WorkspaceSessionPage, error) {
187 state, err := a.workspaceRegistry().Load(context.Background())
188 if err != nil {
189 return WorkspaceSessionPage{}, err
190 }
191 workspace, ok := state.Workspaces[strings.TrimSpace(workspaceID)]
192 if !ok {
193 return WorkspaceSessionPage{}, workspacestate.ErrWorkspaceNotFound
194 }
195 start, err := decodeWorkspaceSessionCursor(cursor, state.Generation)
196 if err != nil {
197 return WorkspaceSessionPage{}, err
198 }
199 if limit <= 0 {
200 limit = 50
201 }
202 if limit > 200 {
203 limit = 200
204 }
205
206 service := a.desktopSessionService("")
207 archived := make(map[string]bool, len(state.ArchivedSessionIDs))
208 for _, id := range state.ArchivedSessionIDs {
209 archived[id] = true
210 }
211 ids := make([]string, 0, len(workspace.SessionIDs))
212 for _, id := range workspace.SessionIDs {
213 if state.SessionStates[id].Lifecycle == workspacestate.Deleted {
214 continue
215 }
216 if includeArchived || !archived[id] {
217 ids = append(ids, id)
218 }
219 }
220 infos, listErr := listWorkspaceSessionInfo(context.Background(), service.Query(), ids)
221 needle := strings.ToLower(strings.TrimSpace(queryText))
222 rows := make([]WorkspaceSessionSummary, 0, len(workspace.SessionIDs))
223 for _, sessionID := range workspace.SessionIDs {
224 if state.SessionStates[sessionID].Lifecycle == workspacestate.Deleted {
225 continue
226 }
227 isArchived := archived[sessionID]
228 if isArchived && !includeArchived {
229 continue
230 }
231 info, found := infos[sessionID]
232 row := workspaceSessionRow(workspace.ID, sessionID, info, found, isArchived, service)
233 if needle != "" && !strings.Contains(strings.ToLower(row.Title+"\n"+row.Preview+"\n"+sessionID), needle) {
234 continue
235 }
236 rows = append(rows, row)
237 }
238 if start > len(rows) {
239 start = len(rows)
240 }
241 end := min(start+limit, len(rows))
242 page := WorkspaceSessionPage{
243 Sessions: append([]WorkspaceSessionSummary{}, rows[start:end]...),
244 RegistryGeneration: state.Generation,
245 }
246 if end < len(rows) {
247 page.NextCursor = fmt.Sprintf("%d:%d", state.Generation, end)
248 }
249 if listErr != nil && len(page.Sessions) == 0 {
250 return page, listErr
251 }
252 return page, nil
253 }
254
255 type workspaceSessionInfoReader interface {
256 Stat(context.Context, session.SessionRef) (session.SessionInfo, error)
257 }
258
259 // The registry already owns membership. Reading each workspace's own headers
260 // avoids a complete catalog traversal for every workspace in a sidebar refresh.
261 func listWorkspaceSessionInfo(ctx context.Context, reader workspaceSessionInfoReader, ids []string) (map[string]session.SessionInfo, error) {
262 infos := make(map[string]session.SessionInfo, len(ids))
263 seen := make(map[string]bool, len(ids))
264 var readErr error
265 for _, id := range ids {
266 if seen[id] {
267 continue
268 }
269 seen[id] = true
270 info, err := reader.Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: id})
271 if errors.Is(err, session.ErrSessionNotFound) {
272 continue
273 }
274 if err != nil {
275 readErr = errors.Join(readErr, err)
276 info = session.SessionInfo{SessionID: id, Error: err.Error(), MetadataStatus: session.MetadataFailed}
277 }
278 infos[id] = info
279 }
280 return infos, readErr
281 }
282
283 func listAllCanonicalSessionInfo(ctx context.Context, query *session.Query) (map[string]session.SessionInfo, error) {
284 infos := map[string]session.SessionInfo{}
285 if query == nil {
286 return infos, errors.New("desktop canonical session query is unavailable")
287 }
288 var cursor string
289 for {
290 page, err := query.List(ctx, cursor, 100)
291 if err != nil {
292 return infos, err
293 }
294 for _, info := range page.Sessions {
295 infos[info.SessionID] = info
296 }
297 if page.NextCursor == "" {
298 return infos, nil
299 }
300 if page.NextCursor == cursor {
301 return infos, errors.New("desktop canonical session cursor did not advance")
302 }
303 cursor = page.NextCursor
304 }
305 }
306
307 func workspaceSessionRow(workspaceID, sessionID string, info session.SessionInfo, found, archived bool, service *session.Service) WorkspaceSessionSummary {
308 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
309 row := WorkspaceSessionSummary{
310 Ref: ref, WorkspaceID: workspaceID, Archived: archived,
311 MetadataStatus: "indexing", Health: "migrating",
312 }
313 if found {
314 row.Title, row.Preview, row.Turns = info.Title, info.Preview, info.Turns
315 row.CreatedAt, row.UpdatedAt = unixMillis(info.CreatedAt), unixMillis(info.UpdatedAt)
316 row.ResultSequence = info.ResultSequence
317 row.ModelRef, row.ParentSessionID, row.Origin = info.ModelRef, info.ParentSessionID, string(info.Origin)
318 row.Blank = info.MetadataStatus == session.MetadataReady && info.Turns == 0 && strings.TrimSpace(info.Title) == "" && strings.TrimSpace(info.Preview) == ""
319 row.MetadataStatus = info.MetadataStatus
320 row.Health = "healthy"
321 if info.Error != "" {
322 row.MetadataStatus, row.Health = "failed", "read_only"
323 }
324 }
325 if service != nil {
326 _, row.Running = service.Runtime(ref)
327 }
328 return row
329 }
330
331 func decodeWorkspaceSessionCursor(cursor string, generation uint64) (int, error) {
332 cursor = strings.TrimSpace(cursor)
333 if cursor == "" {
334 return 0, nil
335 }
336 parts := strings.Split(cursor, ":")
337 if len(parts) != 2 {
338 return 0, errors.New("invalid workspace session cursor")
339 }
340 wantGeneration, err := strconv.ParseUint(parts[0], 10, 64)
341 if err != nil || wantGeneration != generation {
342 return 0, errors.New("workspace session cursor is stale")
343 }
344 offset, err := strconv.Atoi(parts[1])
345 if err != nil || offset < 0 {
346 return 0, errors.New("invalid workspace session cursor")
347 }
348 return offset, nil
349 }
350
351 func validateLocalSessionRef(ref session.SessionRef) error {
352 if ref.HostID != localDesktopHostID || strings.TrimSpace(ref.SessionID) == "" {
353 return errors.New("a local canonical session reference is required")
354 }
355 return nil
356 }
357
358 func (a *App) ArchiveCanonicalSession(ref session.SessionRef) error {
359 _, err := a.archiveCanonicalSessionWithOperation(ref, "archive-"+strings.TrimPrefix(newTabID(), "tab_"))
360 return err
361 }
362
363 func (a *App) RestoreCanonicalSession(ref session.SessionRef) error {
364 _, err := a.restoreCanonicalSessionWithOperation(ref, "restore-"+strings.TrimPrefix(newTabID(), "tab_"))
365 return err
366 }
367
368 func (a *App) MoveWorkspaceSession(workspaceID, sessionID, beforeSessionID string) error {
369 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: strings.TrimSpace(sessionID)}
370 _, err := a.moveWorkspaceSessionWithOperation(
371 ref,
372 workspaceID,
373 beforeSessionID,
374 "move-"+strings.TrimPrefix(newTabID(), "tab_"),
375 )
376 return err
377 }
378
379 func (a *App) archiveCanonicalSessionWithOperation(ref session.SessionRef, operationID string) (SessionTarget, error) {
380 if err := validateLocalSessionRef(ref); err != nil {
381 return SessionTarget{}, err
382 }
383 operationID = strings.TrimSpace(operationID)
384 if operationID == "" {
385 operationID = "archive-" + strings.TrimPrefix(newTabID(), "tab_")
386 }
387 release, ok := a.tryLockRuntimeMutationBounded("archive session")
388 if !ok {
389 return SessionTarget{}, errTopicArchiveBusy
390 }
391 err := a.archiveSessionRefsWithOperation([]session.SessionRef{ref}, operationID)
392 release()
393 if err != nil {
394 return SessionTarget{}, err
395 }
396 a.emitProjectTreeChanged()
397 target, err := a.resolveCanonicalSessionTargetState(ref, "", true)
398 if err != nil {
399 target = SessionTarget{SessionRef: ref}
400 }
401 a.emitSessionTargetChange("session_archived", SessionTargetChangeEvent{
402 TargetKey: target.key(), OperationID: operationID,
403 LifecycleGeneration: target.LifecycleGeneration, WorkspaceID: target.WorkspaceID,
404 })
405 return target, nil
406 }
407
408 func (a *App) restoreCanonicalSessionWithOperation(ref session.SessionRef, operationID string) (SessionTarget, error) {
409 if err := validateLocalSessionRef(ref); err != nil {
410 return SessionTarget{}, err
411 }
412 operationID = strings.TrimSpace(operationID)
413 if operationID == "" {
414 operationID = "restore-" + strings.TrimPrefix(newTabID(), "tab_")
415 }
416 if _, err := a.restoreCanonicalSession(a.bootContext(), ref, operationID); err != nil {
417 return SessionTarget{}, err
418 }
419 target, err := a.resolveCanonicalSessionTargetState(ref, "", true)
420 if err != nil {
421 target = SessionTarget{SessionRef: ref}
422 }
423 a.emitSessionTargetChange("session_restored", SessionTargetChangeEvent{
424 TargetKey: target.key(), OperationID: operationID,
425 LifecycleGeneration: target.LifecycleGeneration, WorkspaceID: target.WorkspaceID,
426 })
427 return target, nil
428 }
429
430 func (a *App) moveWorkspaceSessionWithOperation(ref session.SessionRef, workspaceID, beforeSessionID, operationID string) (SessionTarget, error) {
431 if err := validateLocalSessionRef(ref); err != nil {
432 return SessionTarget{}, err
433 }
434 target, err := a.resolveCanonicalSessionTarget(ref, "")
435 if err != nil {
436 return SessionTarget{}, err
437 }
438 workspaceID = strings.TrimSpace(workspaceID)
439 if workspaceID == "" || target.WorkspaceID != workspaceID {
440 return SessionTarget{}, workspacestate.ErrMutationConflict
441 }
442 operationID = strings.TrimSpace(operationID)
443 if operationID == "" {
444 operationID = "move-" + strings.TrimPrefix(newTabID(), "tab_")
445 }
446 a.cancelAISessionTitle(target.key())
447 if err := a.workspaceRegistry().MoveSessionIfUnchanged(
448 context.Background(),
449 workspaceID,
450 ref.SessionID,
451 beforeSessionID,
452 target.LifecycleGeneration,
453 ); err != nil {
454 return SessionTarget{}, err
455 }
456 a.emitProjectTreeChanged()
457 target, err = a.resolveCanonicalSessionTargetState(ref, "", true)
458 if err != nil {
459 target = SessionTarget{SessionRef: ref, WorkspaceID: workspaceID}
460 }
461 a.emitSessionTargetChange("session_moved", SessionTargetChangeEvent{
462 TargetKey: target.key(), OperationID: operationID,
463 LifecycleGeneration: target.LifecycleGeneration, WorkspaceID: workspaceID,
464 })
465 return target, nil
466 }
467
468 func (a *App) RenameWorkspace(workspaceID, title string) error {
469 if err := a.workspaceRegistry().RenameWorkspace(context.Background(), workspaceID, title); err != nil {
470 return err
471 }
472 a.emitProjectTreeChanged()
473 return nil
474 }
475
476 func (a *App) SetWorkspaceVisible(workspaceID string, visible bool) error {
477 if strings.TrimSpace(workspaceID) == workspacestate.GlobalWorkspaceID && !visible {
478 return errors.New("the global workspace cannot be hidden")
479 }
480 if err := a.workspaceRegistry().SetWorkspaceVisible(context.Background(), workspaceID, visible); err != nil {
481 return err
482 }
483 a.emitProjectTreeChanged()
484 return nil
485 }
486
487 func (a *App) MoveWorkspace(workspaceID, beforeWorkspaceID string) error {
488 if err := a.workspaceRegistry().MoveWorkspace(context.Background(), workspaceID, beforeWorkspaceID); err != nil {
489 return err
490 }
491 a.emitProjectTreeChanged()
492 return nil
493 }
494
495 // CreateSession is the SessionID-only creation facade used by the Workspace
496 // browser. The existing controller creation transaction still owns prompt/model
497 // seeding; this method only resolves a durable Workspace identity to that flow.
498 func (a *App) CreateSession(workspaceID string) (session.SessionRef, error) {
499 workspaceID = strings.TrimSpace(workspaceID)
500 if workspaceID == workspacestate.GlobalWorkspaceID {
501 if _, err := a.ensureDesktopWorkspace(context.Background(), "global", ""); err != nil {
502 return session.SessionRef{}, err
503 }
504 }
505 state, err := a.workspaceRegistry().Load(context.Background())
506 if err != nil {
507 return session.SessionRef{}, err
508 }
509 workspace, ok := state.Workspaces[workspaceID]
510 if !ok {
511 return session.SessionRef{}, workspacestate.ErrWorkspaceNotFound
512 }
513 scope, root := "project", workspace.Root
514 if workspace.ID == workspacestate.GlobalWorkspaceID {
515 scope, root = "global", ""
516 }
517 meta, err := a.EnsureBlankSurface(scope, root)
518 if err != nil {
519 return session.SessionRef{}, err
520 }
521 if meta.Session != nil {
522 return *meta.Session, nil
523 }
524 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: meta.SessionID}
525 return ref, validateLocalSessionRef(ref)
526 }
527
528 // ForkSession creates an independently routed canonical child and publishes it
529 // immediately after its parent in the same Workspace. An empty boundary means
530 // the latest completed turn; no message-count inference is used.
531 // CopySessionTarget creates a full-history copy under a new durable identity.
532 // The caller-supplied operation id makes retries idempotent across storage
533 // publication and workspace attachment. The copy is never opened or selected.
534 func (a *App) CopySessionTarget(selector SessionSelector, operationID string) (SessionCreationResult, error) {
535 target, err := a.resolveSessionMutationTarget(selector)
536 if err != nil {
537 return SessionCreationResult{}, err
538 }
539 key := target.key()
540 operationID = strings.TrimSpace(operationID)
541 if operationID == "" {
542 operationID = "copy-" + strings.TrimPrefix(newTabID(), "tab_")
543 }
544 sum := sha256.Sum256([]byte(key + "\x00" + operationID))
545 childID := fmt.Sprintf("desktop-copy-%x", sum[:12])
546 childRef := session.SessionRef{HostID: localDesktopHostID, SessionID: childID}
547
548 workspaceID := strings.TrimSpace(target.WorkspaceID)
549 if workspaceID == "" {
550 workspaceID, err = a.ensureDesktopWorkspace(a.bootContext(), target.Scope, target.WorkspaceRoot)
551 if err != nil {
552 return SessionCreationResult{}, sessionOperationErrorForTarget(err, key, operationID)
553 }
554 }
555 beforeID := ""
556 if target.SessionRef.SessionID != "" {
557 if state, loadErr := a.workspaceRegistry().Load(a.bootContext()); loadErr == nil {
558 if workspace, ok := state.Workspaces[workspaceID]; ok {
559 for index, id := range workspace.SessionIDs {
560 if id == target.SessionRef.SessionID && index+1 < len(workspace.SessionIDs) {
561 beforeID = workspace.SessionIDs[index+1]
562 break
563 }
564 }
565 }
566 }
567 }
568 if err := a.workspaceRegistry().BeginCreate(a.bootContext(), workspacestate.PendingCreate{
569 OperationID: operationID, WorkspaceID: workspaceID, SessionID: childID,
570 }); err != nil {
571 return SessionCreationResult{}, sessionOperationErrorForTarget(err, key, operationID)
572 }
573
574 service := a.desktopSessionService("")
575 cwd := desktopWorkspaceRoot(target.Scope, target.WorkspaceRoot)
576 if target.SessionRef.SessionID != "" {
577 _, err = service.CopySession(a.bootContext(), session.CopyRequest{
578 Source: target.SessionRef, ChildID: childID, OperationID: operationID, CWD: cwd,
579 })
580 } else if strings.TrimSpace(target.SessionPath) == "" {
581 err = newSessionOperationError(sessionOperationNoMessages, "This session has no conversation history to copy.")
582 } else {
583 err = a.copyLegacySessionTarget(target, childRef, operationID, cwd)
584 }
585 if err != nil {
586 _ = a.workspaceRegistry().AbortCreate(context.Background(), childID)
587 return SessionCreationResult{}, sessionOperationErrorForTarget(err, key, operationID)
588 }
589 var attachErr error
590 if target.SessionRef.SessionID != "" {
591 attachErr = a.workspaceRegistry().AttachSessionFromSourceIfUnchanged(
592 a.bootContext(),
593 operationID,
594 workspaceID,
595 childID,
596 beforeID,
597 target.SessionRef.SessionID,
598 target.LifecycleGeneration,
599 )
600 } else {
601 attachErr = a.workspaceRegistry().AttachSession(a.bootContext(), operationID, workspaceID, childID, beforeID)
602 }
603 if attachErr != nil {
604 if target.SessionRef.SessionID != "" && errors.Is(attachErr, workspacestate.ErrMutationConflict) {
605 _ = service.Delete(context.Background(), childRef)
606 _ = a.workspaceRegistry().AbortCreate(context.Background(), childID)
607 return SessionCreationResult{}, sessionOperationErrorForTarget(attachErr, key, operationID)
608 }
609 // Storage already committed. Preserve the pending-create journal so a
610 // retry with the same operation can finish attachment.
611 return SessionCreationResult{
612 Ref: childRef, OperationID: operationID, Committed: true, ProjectionPending: true,
613 }, nil
614 }
615 a.emitProjectTreeChanged()
616 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
617 TargetKey: "ref:" + childRef.HostID + ":" + childRef.SessionID,
618 OperationID: operationID, WorkspaceID: workspaceID,
619 })
620 return SessionCreationResult{Ref: childRef, OperationID: operationID, Committed: true}, nil
621 }
622
623 func (a *App) copyLegacySessionTarget(target SessionTarget, child session.SessionRef, operationID, cwd string) error {
624 container, err := os.MkdirTemp("", "reasonix-legacy-copy-")
625 if err != nil {
626 return err
627 }
628 defer os.RemoveAll(container)
629 migrationRoot := filepath.Join(container, "migrated")
630 migrated, err := session.MigrateLegacy(a.bootContext(), target.SessionPath, migrationRoot)
631 if err != nil {
632 return err
633 }
634 service := a.desktopSessionService("")
635 if matched, matchErr := service.CopyOperationMatches(a.bootContext(), child, migrated.TargetID, operationID); matchErr == nil && matched {
636 return nil
637 }
638 staging, err := session.NewService(localDesktopHostID, session.NewFilesystemPersistence(migrationRoot))
639 if err != nil {
640 return err
641 }
642 defer func() { _ = staging.CloseAll(context.Background()) }()
643 source := session.SessionRef{HostID: localDesktopHostID, SessionID: migrated.TargetID}
644 copied, err := staging.CopySession(a.bootContext(), session.CopyRequest{
645 Source: source, ChildID: child.SessionID, OperationID: operationID, CWD: cwd,
646 })
647 if err != nil {
648 return err
649 }
650 bundle := filepath.Join(container, "bundle")
651 if err := staging.Export(a.bootContext(), copied.Child, bundle); err != nil {
652 return err
653 }
654 _, err = service.ImportWithHeader(a.bootContext(), bundle, session.CreateOptions{
655 SessionID: child.SessionID, CWD: cwd, Origin: session.SessionOriginLegacyImport,
656 })
657 if err == nil {
658 return nil
659 }
660 if matched, matchErr := service.CopyOperationMatches(a.bootContext(), child, migrated.TargetID, operationID); matchErr == nil && matched {
661 return nil
662 }
663 return err
664 }
665
666 func (a *App) ReadSessionHistory(ref session.SessionRef, cursor string, limit int) (HistoryPage, error) {
667 if err := validateLocalSessionRef(ref); err != nil {
668 return HistoryPage{}, err
669 }
670 beforeTurn := 0
671 if strings.TrimSpace(cursor) != "" {
672 parsed, err := strconv.Atoi(cursor)
673 if err != nil || parsed < 0 {
674 return HistoryPage{}, errors.New("invalid session history cursor")
675 }
676 beforeTurn = parsed
677 }
678 messages, err := a.desktopSessionService("").Query().History(a.bootContext(), ref)
679 if err != nil {
680 return HistoryPage{}, err
681 }
682 page := historyPageFromProviderMessages(messages, func(content string) string { return content }, nil, nil, beforeTurn, limit)
683 digest, _ := agent.ContentDigestForMessages(messages)
684 return historyPageWithFingerprint(page, sessionRoute(ref.SessionID), digest), nil
685 }
686
687 // OpenSession installs exactly ref into the current local surface. It first
688 // proves the target identity and workspace exist; a missing or damaged identity
689 // never creates an empty replacement and never clears the currently visible log.
690 // History bodies are loaded after the runtime commits so a live writer is not
691 // snapshotted on the navigation goroutine.
692 func (a *App) OpenSession(ref session.SessionRef) (HistoryPage, error) {
693 return a.openSessionWithNavigation(ref, a.desktopSessions.navigationSeq.Add(1))
694 }
695
696 func (a *App) openSessionWithNavigation(ref session.SessionRef, navigationSequence uint64) (HistoryPage, error) {
697 if a.desktopSessions.navigationSeq.Load() != navigationSequence {
698 return HistoryPage{}, errSessionNavigationSuperseded
699 }
700 if err := validateLocalSessionRef(ref); err != nil {
701 return HistoryPage{}, err
702 }
703 if _, err := a.desktopSessionService("").Query().Stat(a.bootContext(), ref); err != nil {
704 return HistoryPage{}, err
705 }
706 if a.desktopSessions.navigationSeq.Load() != navigationSequence {
707 return HistoryPage{}, errSessionNavigationSuperseded
708 }
709 workspace, err := a.canonicalSessionWorkspace(a.bootContext(), ref)
710 if err != nil {
711 return HistoryPage{}, err
712 }
713 if opened, err := a.openManualCreationSurface(ref, navigationSequence); opened || err != nil {
714 return HistoryPage{Messages: []HistoryMessage{}}, err
715 }
716 tab, ctrl, created, err := a.surfaceForCanonicalSession(ref, workspace)
717 if err != nil {
718 return HistoryPage{}, err
719 }
720 if _, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, sessionRoute(ref.SessionID), defaultHistoryPageTurns, false, navigationSequence); err != nil {
721 if created {
722 a.discardUnboundSurface(tab)
723 }
724 return HistoryPage{}, err
725 }
726 a.selectLocalSurfaceAfterOpen(navigationSequence)
727 // runtime:rebuilt intentionally has no reload semantics. SessionRef opening
728 // is navigation, so publish ready only after the exact target commits and
729 // let every frontend owner re-read its metadata and history.
730 a.emitReady(a.bootContext(), tab.ID)
731 return HistoryPage{Messages: []HistoryMessage{}}, nil
732 }
733
734 func (a *App) RenameCanonicalSession(ref session.SessionRef, title string) error {
735 if err := validateLocalSessionRef(ref); err != nil {
736 return err
737 }
738 target, err := a.resolveCanonicalSessionTarget(ref, "")
739 if err != nil {
740 return err
741 }
742 return a.renameCanonicalSessionTarget(target, title)
743 }
744
745 // SetSessionPinned updates canonical presentation directly. A historical
746 // source keeps its lightweight topic preference without converting content;
747 // import transfers that presentation when the target is committed.
748 func (a *App) SetSessionPinned(selector SessionSelector, pinned bool) error {
749 target, err := a.resolveSessionTarget(selector)
750 if err != nil {
751 return err
752 }
753 if target.SessionRef.SessionID == "" && target.Source != nil {
754 value := pinned
755 if err := a.saveHistoricalSourcePresentation(target.Source.SourceKey, func(presentation *historicalSourcePresentation) {
756 presentation.Pinned = &value
757 }); err != nil {
758 return err
759 }
760 a.emitProjectTreeMetadataChanged()
761 return nil
762 }
763 if target.SessionRef.SessionID == "" {
764 return newSessionOperationError(sessionOperationNoMessages, "This empty session has no durable preference yet.")
765 }
766 if err := a.workspaceRegistry().UpdatePresentation(a.bootContext(), []string{target.SessionRef.SessionID}, nil, &pinned); err != nil {
767 return err
768 }
769 a.emitProjectTreeMetadataChanged()
770 return nil
771 }
772
773 func (a *App) renameCanonicalSessionTarget(target SessionTarget, title string) error {
774 ref := target.SessionRef
775 if err := validateLocalSessionRef(ref); err != nil {
776 return err
777 }
778 a.cancelAISessionTitle(target.key())
779 a.topicTitleMutationMu.Lock()
780 defer a.topicTitleMutationMu.Unlock()
781 err := a.workspaceRegistry().WithSessionUnchanged(
782 a.bootContext(),
783 ref.SessionID,
784 target.WorkspaceID,
785 target.LifecycleGeneration,
786 func() error {
787 return a.desktopSessionService("").SetTitle(a.bootContext(), ref, strings.TrimSpace(title))
788 },
789 )
790 if err != nil {
791 return err
792 }
793 a.publishCanonicalSessionTitle(ref, title)
794 return nil
795 }
796
796 lines GO