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