返回 DeepSeek-Reasonix
session_workspace_fork.go
根目录 / desktop / session_workspace_fork.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "reasonix/desktop/internal/workspacestate"
7 "reasonix/internal/session"
8 "slices"
9 "strings"
10 )
11
12 func (a *App) ForkSession(ref session.SessionRef, turnBoundary string) (session.SessionRef, error) {
13 plan, err := a.planCanonicalFork(ref, turnBoundary)
14 if err != nil {
15 return session.SessionRef{}, err
16 }
17 operationID := "fork-" + strings.TrimPrefix(newTabID(), "tab_")
18 childID := "desktop-" + strings.TrimPrefix(newTabID(), "tab_")
19 return a.executeCanonicalFork(ref, plan, operationID, childID)
20 }
21
22 type canonicalForkPlan struct {
23 workspaceID string
24 beforeID string
25 sourceGeneration uint64
26 target session.ForkTarget
27 }
28
29 func (a *App) planCanonicalFork(ref session.SessionRef, turnBoundary string) (canonicalForkPlan, error) {
30 if err := validateLocalSessionRef(ref); err != nil {
31 return canonicalForkPlan{}, err
32 }
33 state, err := a.workspaceRegistry().Load(context.Background())
34 if err != nil {
35 return canonicalForkPlan{}, err
36 }
37 workspaceID, beforeID := "", ""
38 for _, id := range state.WorkspaceIDs {
39 workspace := state.Workspaces[id]
40 for index, sessionID := range workspace.SessionIDs {
41 if sessionID != ref.SessionID {
42 continue
43 }
44 workspaceID = id
45 if index+1 < len(workspace.SessionIDs) {
46 beforeID = workspace.SessionIDs[index+1]
47 }
48 break
49 }
50 if workspaceID != "" {
51 break
52 }
53 }
54 if workspaceID == "" {
55 return canonicalForkPlan{}, workspacestate.ErrSessionNotFound
56 }
57 sourceState := state.SessionStates[ref.SessionID]
58 if sourceState.Lifecycle != workspacestate.Active {
59 return canonicalForkPlan{}, workspacestate.ErrMutationConflict
60 }
61 targets, err := a.desktopSessionService("").ForkTargetSetFor(a.bootContext(), ref)
62 if err != nil {
63 return canonicalForkPlan{}, err
64 }
65 turnBoundary = strings.TrimSpace(turnBoundary)
66 var selected session.ForkTarget
67 if turnBoundary == "" {
68 for _, target := range slices.Backward(targets.Targets) {
69 if target.Available {
70 selected = target
71 break
72 }
73 }
74 if selected.TurnID == "" {
75 return canonicalForkPlan{}, errors.New("session has no completed turn to fork")
76 }
77 } else {
78 for _, target := range targets.Targets {
79 if target.TurnID == turnBoundary {
80 selected = target
81 break
82 }
83 }
84 if selected.TurnID == "" || !selected.Available {
85 reason := selected.Reason
86 if reason == "" {
87 reason = session.ForkHistoryUnverifiable
88 }
89 return canonicalForkPlan{}, &session.ForkUnavailableError{TurnID: turnBoundary, Reason: reason}
90 }
91 }
92 return canonicalForkPlan{
93 workspaceID: workspaceID, beforeID: beforeID,
94 sourceGeneration: sourceState.Generation, target: selected,
95 }, nil
96 }
97
98 func (a *App) executeCanonicalFork(ref session.SessionRef, plan canonicalForkPlan, operationID, childID string) (session.SessionRef, error) {
99 operationID = strings.TrimSpace(operationID)
100 childID = strings.TrimSpace(childID)
101 if operationID == "" || childID == "" {
102 return session.SessionRef{}, errors.New("fork operation and child identities are required")
103 }
104 stateBefore, err := a.workspaceRegistry().Load(a.bootContext())
105 if err != nil {
106 return session.SessionRef{}, err
107 }
108 w := stateBefore.Workspaces[plan.workspaceID]
109 scope := "project"
110 if plan.workspaceID == workspacestate.GlobalWorkspaceID {
111 scope = "global"
112 }
113 if _, _, err := a.ensureSessionOrganization(scope, w.Root); err != nil {
114 return session.SessionRef{}, err
115 }
116 if err := a.workspaceRegistry().BeginCreate(a.bootContext(), workspacestate.PendingCreate{
117 OperationID: operationID, WorkspaceID: plan.workspaceID, SessionID: childID, ParentSessionID: ref.SessionID,
118 }); err != nil {
119 return session.SessionRef{}, err
120 }
121 forked, err := a.desktopSessionService("").CreateFork(a.bootContext(), session.ForkRequest{
122 Source: ref, TurnID: plan.target.TurnID, BoundarySequence: plan.target.BoundarySequence,
123 OperationID: operationID, ChildID: childID,
124 })
125 if err != nil {
126 _ = a.workspaceRegistry().AbortCreate(context.Background(), childID)
127 return session.SessionRef{}, err
128 }
129 if _, already := stateBefore.SessionStates[childID]; !already {
130 // Forks are ordinary independent sessions. Give the child its own title and
131 // presentation while retaining ParentSessionID in the session header.
132 parentTitle := ""
133 if infos, infoErr := listWorkspaceSessionInfo(a.bootContext(), a.desktopSessionService("").Query(), []string{ref.SessionID}); infoErr == nil {
134 parentTitle = strings.TrimSpace(infos[ref.SessionID].Title)
135 }
136 if parentTitle == "" {
137 if state, stateErr := a.workspaceRegistry().Load(a.bootContext()); stateErr == nil {
138 parentTitle = strings.TrimSpace(state.Presentation[ref.SessionID].Title)
139 }
140 }
141 if parentTitle == "" {
142 parentTitle = a.localizedDefaultTopicTitle()
143 }
144 forkTitle := parentTitle + a.localizedForkTitleSuffix()
145 if err := a.desktopSessionService("").SetTitle(a.bootContext(), forked.Child, forkTitle); err != nil {
146 return session.SessionRef{}, err
147 }
148 if err := a.workspaceRegistry().PrepareCreatePresentation(a.bootContext(), childID, workspacestate.Presentation{Title: forkTitle, SortOrder: -1}); err != nil {
149 return session.SessionRef{}, err
150 }
151 }
152 if err := a.workspaceRegistry().AttachSessionFromSourceIfUnchanged(
153 a.bootContext(),
154 operationID,
155 plan.workspaceID,
156 childID,
157 plan.beforeID,
158 ref.SessionID,
159 plan.sourceGeneration,
160 ); err != nil {
161 // The child transcript is already durable. Preserve the pending create
162 // so the same operation can finish publication without copying history.
163 return session.SessionRef{}, err
164 }
165 a.emitProjectTreeChanged()
166 return forked.Child, nil
167 }
168
169 func (a *App) localizedForkTitleSuffix() string {
170 switch a.desktopLocale.Load() {
171 case desktopLocaleEn:
172 return " · Fork"
173 case desktopLocaleZhTW:
174 return " · 分叉"
175 default:
176 return " · 分叉"
177 }
178 }
179
180 // ForkSessionTarget forks one explicit durable target without staging it into
181 // the current tab. Legacy-only sources must first be adopted into canonical
182 // storage so turn boundaries remain verifiable.
183 func (a *App) ForkSessionTarget(selector SessionSelector, turnBoundary string) (session.SessionRef, error) {
184 target, err := a.resolveSessionMutationTarget(selector)
185 if err != nil {
186 return session.SessionRef{}, err
187 }
188 if target.SessionRef.SessionID == "" {
189 return session.SessionRef{}, newSessionOperationError("unsupported", "This historical session has no verifiable canonical turn boundaries.")
190 }
191 plan, err := a.planCanonicalFork(target.SessionRef, turnBoundary)
192 if err != nil {
193 return session.SessionRef{}, sessionOperationErrorForTarget(err, target.key(), "")
194 }
195 operation, err := a.beginForkOperation(forkOperation{
196 Surface: "target", SourceHostID: target.SessionRef.HostID, SourceSessionID: target.SessionRef.SessionID,
197 TurnID: plan.target.TurnID, BoundarySequence: plan.target.BoundarySequence,
198 })
199 if err != nil {
200 return session.SessionRef{}, sessionOperationErrorForTarget(err, target.key(), "")
201 }
202 if operation.State == "completed" && strings.TrimSpace(operation.ChildSessionID) != "" {
203 return session.SessionRef{HostID: localDesktopHostID, SessionID: operation.ChildSessionID}, nil
204 }
205 childID := "desktop-" + strings.TrimPrefix(operation.OperationID, "fork_")
206 child, err := a.executeCanonicalFork(target.SessionRef, plan, operation.OperationID, childID)
207 if err != nil {
208 var unavailable *session.ForkUnavailableError
209 if errors.As(err, &unavailable) {
210 _ = a.discardForkOperation(operation.OperationID)
211 }
212 return session.SessionRef{}, sessionOperationErrorForTarget(err, target.key(), operation.OperationID)
213 }
214 if err := a.completeForkOperation(operation.OperationID, child.SessionID); err != nil {
215 return session.SessionRef{}, sessionOperationErrorForTarget(err, target.key(), operation.OperationID)
216 }
217 return child, nil
218 }
219
219 lines GO