返回 DeepSeek-Reasonix
session_organization.go
根目录 / desktop / session_organization.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "slices"
7 "strings"
8
9 "reasonix/desktop/internal/workspacestate"
10 "reasonix/internal/session"
11 )
12
13 type SessionOrganizationWorkspace struct {
14 Scope string `json:"scope"`
15 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
16 HostID string `json:"hostId,omitempty"`
17 }
18 type SessionOrganizationSnapshot struct {
19 Revision uint64 `json:"revision"`
20 Applied bool `json:"applied"`
21 ManualOrderEnabled bool `json:"manualOrderEnabled"`
22 Order []string `json:"order"`
23 Groups []desktopGroup `json:"groups"`
24 }
25 type SessionOrganizationMutation struct {
26 Kind string `json:"kind"`
27 Target *SessionSelector `json:"target,omitempty"`
28 Anchor *SessionSelector `json:"anchor,omitempty"`
29 Position string `json:"position,omitempty"`
30 GroupID string `json:"groupId,omitempty"`
31 Title string `json:"title,omitempty"`
32 }
33
34 func organizationSnapshot(o workspacestate.Organization, applied bool) SessionOrganizationSnapshot {
35 groups := []desktopGroup{}
36 for _, g := range o.Groups {
37 groups = append(groups, desktopGroup{ID: g.ID, Title: g.Title, SessionKeys: append([]string{}, g.Members...)})
38 }
39 return SessionOrganizationSnapshot{Revision: o.Revision, Applied: applied, ManualOrderEnabled: o.ManualOrderEnabled, Order: append([]string{}, o.Order...), Groups: groups}
40 }
41
42 // Import known sources incrementally. Imported includes explicit ungrouped
43 // choices, so later discoveries never reinstate a topic-level preference.
44 func (a *App) ensureSessionOrganization(scope, root string) (string, workspacestate.Organization, error) {
45 scope, root, err := normalizeOrganizationTarget(scope, root)
46 if err != nil {
47 return "", workspacestate.Organization{}, err
48 }
49 id, err := a.ensureDesktopWorkspace(a.bootContext(), scope, root)
50 if err != nil {
51 return "", workspacestate.Organization{}, err
52 }
53 state, err := a.workspaceRegistry().Load(a.bootContext())
54 if err != nil {
55 return "", workspacestate.Organization{}, err
56 }
57 workspace := state.Workspaces[id]
58 projects := loadProjectsFile()
59 groups := projects.GlobalGroups
60 order := projects.GlobalSessionOrder
61 topicOrder := projects.GlobalTopics
62 manual := projects.GlobalManualSessionOrder || projects.GlobalManualTopicOrder
63 if scope == "project" {
64 if i := projectIndexByRoot(projects.Projects, root); i >= 0 {
65 p := projects.Projects[i]
66 groups, order, manual = p.Groups, p.SessionOrder, p.ManualSessionOrder || p.ManualTopicOrder
67 topicOrder = p.Topics
68 }
69 }
70 nodes := []ProjectNode{}
71 for _, sid := range workspace.SessionIDs {
72 p := state.Presentation[sid]
73 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid}
74 node := ProjectNode{Session: &ref, TopicID: p.TopicID, SessionPath: sessionRoute(sid)}
75 node.IdentityAliases = sourceAliases(state, id, sid)
76 nodes = append(nodes, node)
77 }
78 req := ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200}
79 for {
80 page, e := a.listProjectTopics(req)
81 if e != nil {
82 return "", workspacestate.Organization{}, e
83 }
84 for _, node := range page.Items {
85 nodes = append(nodes, expandSessionSourceRows(node)...)
86 }
87 if page.NextCursor == "" {
88 break
89 }
90 if page.NextCursor == req.Cursor {
91 return "", workspacestate.Organization{}, fmt.Errorf("legacy cursor did not advance")
92 }
93 req.Cursor = page.NextCursor
94 }
95 canonicalByAlias := map[string]string{}
96 for _, n := range nodes {
97 if n.Session != nil {
98 for _, alias := range n.IdentityAliases {
99 canonicalByAlias[alias] = projectNodeSessionKey(n)
100 }
101 }
102 }
103 org, _, err := a.workspaceRegistry().UpdateOrganization(a.bootContext(), id, nil, func(o *workspacestate.Organization) error {
104 initial := o.MigrationVersion == 0
105 if initial {
106 o.ManualOrderEnabled = manual
107 for _, g := range groups {
108 o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: g.ID, Title: g.Title, Members: []string{}})
109 }
110 }
111 importOrganizationMembers(o, nodes, groups, canonicalByAlias)
112 if initial {
113 importOrganizationOrder(o, nodes, order, topicOrder, canonicalByAlias, manual)
114 }
115 o.MigrationVersion = 1
116 return nil
117 })
118 return id, org, err
119 }
120
121 func sourceAliases(state workspacestate.State, workspaceID, sessionID string) []string {
122 aliases := []string{}
123 for _, m := range state.SourceMappings {
124 if m.WorkspaceID != workspaceID || m.SessionID != sessionID {
125 continue
126 }
127 aliases = append(aliases, "source\x00local\x00"+m.SourceKey)
128 if sourceMappingHasPathAlias(m) {
129 aliases = append(aliases, "path\x00"+m.Path)
130 }
131 }
132 slices.Sort(aliases)
133 return aliases
134 }
135
136 func (a *App) GetSessionOrganization(workspace SessionOrganizationWorkspace) (SessionOrganizationSnapshot, error) {
137 if workspace.HostID != "" && workspace.HostID != localDesktopHostID {
138 return a.remoteSessionOrganization(workspace, nil, nil)
139 }
140 _, o, err := a.ensureSessionOrganization(workspace.Scope, workspace.WorkspaceRoot)
141 return organizationSnapshot(o, err == nil), err
142 }
143
144 func (a *App) UpdateSessionOrganization(workspace SessionOrganizationWorkspace, expectedRevision uint64, mutation SessionOrganizationMutation) (SessionOrganizationSnapshot, error) {
145 if workspace.HostID != "" && workspace.HostID != localDesktopHostID {
146 return a.remoteSessionOrganization(workspace, &expectedRevision, &mutation)
147 }
148 id, _, err := a.ensureSessionOrganization(workspace.Scope, workspace.WorkspaceRoot)
149 if err != nil {
150 return SessionOrganizationSnapshot{}, err
151 }
152 resolved := []SessionTarget{}
153 resolve := func(selector *SessionSelector) (string, error) {
154 if selector == nil {
155 return "", newSessionOperationError("target_not_found", "Select a session.")
156 }
157 target, e := a.resolveSessionTarget(*selector)
158 if e != nil {
159 return "", e
160 }
161 targetWorkspaceID, e := a.resolveDesktopWorkspaceID(a.bootContext(), target.Scope, target.WorkspaceRoot)
162 if e != nil {
163 return "", e
164 }
165 if targetWorkspaceID != id {
166 return "", newSessionOperationError("target_changed", "The session moved to another workspace.")
167 }
168 resolved = append(resolved, target)
169 var ref *session.SessionRef
170 if target.SessionRef.SessionID != "" {
171 ref = &target.SessionRef
172 }
173 return projectNodeSessionKey(ProjectNode{Session: ref, SessionPath: target.SessionPath, Source: selector.Source}), nil
174 }
175 key, anchor := "", ""
176 if mutation.Kind == "move" || mutation.Kind == "set-group" {
177 key, err = resolve(mutation.Target)
178 if err != nil {
179 return SessionOrganizationSnapshot{}, err
180 }
181 }
182 if mutation.Kind == "move" {
183 anchor, err = resolve(mutation.Anchor)
184 if err != nil {
185 return SessionOrganizationSnapshot{}, err
186 }
187 }
188 o, applied, err := a.workspaceRegistry().UpdateOrganizationWithState(a.bootContext(), id, &expectedRevision, func(state *workspacestate.State, o *workspacestate.Organization) error {
189 for _, target := range resolved {
190 if target.SessionRef.SessionID == "" {
191 continue
192 }
193 current := state.SessionStates[target.SessionRef.SessionID]
194 if current.Lifecycle != workspacestate.Active || current.Generation != target.LifecycleGeneration || !slices.Contains(state.Workspaces[id].SessionIDs, target.SessionRef.SessionID) {
195 return workspacestate.ErrMutationConflict
196 }
197 }
198 return applyOrganizationMutation(o, mutation, key, anchor)
199 })
200 if err == nil && applied {
201 a.emitProjectTreeMetadataChanged()
202 }
203 return organizationSnapshot(o, applied), err
204 }
205
206 // replaceSessionOrganizationGroups retains old RPC signatures while moving their
207 // persistence into the same transaction as ordering and lifecycle mutations.
208 func (a *App) replaceSessionOrganizationGroups(ctx context.Context, scope, root string, revision *uint64, groups []desktopGroup) (ProjectGroupsSnapshot, error) {
209 id, _, err := a.ensureSessionOrganization(scope, root)
210 if err != nil {
211 return ProjectGroupsSnapshot{}, err
212 }
213 if err = validateSessionGroups(groups); err != nil {
214 return ProjectGroupsSnapshot{}, err
215 }
216 state, err := a.workspaceRegistry().Load(ctx)
217 if err != nil {
218 return ProjectGroupsSnapshot{}, err
219 }
220 legacy, err := a.unadoptedLegacyTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200}, map[string]bool{}, map[string]bool{})
221 if err != nil {
222 return ProjectGroupsSnapshot{}, err
223 }
224 nodes := append([]ProjectNode{}, legacy.Items...)
225 for _, sid := range state.Workspaces[id].SessionIDs {
226 if state.SessionStates[sid].Lifecycle != workspacestate.Active {
227 continue
228 }
229 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid}
230 nodes = append(nodes, ProjectNode{Session: &ref, TopicID: state.Presentation[sid].TopicID})
231 }
232 o, applied, err := a.workspaceRegistry().UpdateOrganization(ctx, id, revision, func(o *workspacestate.Organization) error {
233 next := []workspacestate.OrganizationGroup{}
234 for _, g := range groups {
235 members := []string{}
236 for _, node := range nodes {
237 key := projectNodeSessionKey(node)
238 if o.Imported[key] && desktopGroupContainsNode(g, node) && !slices.Contains(members, key) {
239 members = append(members, key)
240 }
241 }
242 for _, key := range g.SessionKeys {
243 if !o.Imported[key] {
244 return workspacestate.ErrMutationConflict
245 }
246 if !slices.Contains(members, key) {
247 members = append(members, key)
248 }
249 }
250 group := workspacestate.OrganizationGroup{ID: g.ID, Title: g.Title, Members: members}
251 for _, existing := range o.Groups {
252 if existing.ID == g.ID {
253 group = existing
254 group.Title, group.Members = g.Title, members
255 break
256 }
257 }
258 next = append(next, group)
259 }
260 o.Groups = next
261 return nil
262 })
263 if err == nil && applied {
264 a.emitProjectTreeMetadataChanged()
265 }
266 s := organizationSnapshot(o, applied)
267 return ProjectGroupsSnapshot{Groups: s.Groups, Revision: s.Revision, Applied: applied}, err
268 }
269
270 func applyOrganizationMutation(o *workspacestate.Organization, mutation SessionOrganizationMutation, key, anchor string) error {
271 switch mutation.Kind {
272 case "move":
273 if key == anchor {
274 return nil
275 }
276 if !o.Imported[key] || !o.Imported[anchor] {
277 return workspacestate.ErrMutationConflict
278 }
279 if mutation.Position != "before" && mutation.Position != "after" {
280 return fmt.Errorf("invalid position")
281 }
282 o.Order = slices.DeleteFunc(o.Order, func(v string) bool { return v == key })
283 i := slices.Index(o.Order, anchor)
284 if i < 0 {
285 return workspacestate.ErrMutationConflict
286 }
287 if mutation.Position == "after" {
288 i++
289 }
290 o.Order = slices.Insert(o.Order, i, key)
291 o.ManualOrderEnabled = true
292 case "set-group":
293 if !o.Imported[key] {
294 return workspacestate.ErrMutationConflict
295 }
296 found := mutation.GroupID == ""
297 for _, g := range o.Groups {
298 found = found || g.ID == mutation.GroupID
299 }
300 if !found {
301 return workspacestate.ErrMutationConflict
302 }
303 for i := range o.Groups {
304 o.Groups[i].Members = slices.DeleteFunc(o.Groups[i].Members, func(v string) bool { return v == key })
305 if o.Groups[i].ID == mutation.GroupID {
306 o.Groups[i].Members = append(o.Groups[i].Members, key)
307 }
308 }
309 case "create-group":
310 if len(o.Groups) >= maxSessionGroups {
311 return fmt.Errorf("group limit exceeded")
312 }
313 if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil {
314 return err
315 }
316 if strings.TrimSpace(mutation.GroupID) == "" || strings.TrimSpace(mutation.Title) == "" {
317 return fmt.Errorf("group id and title required")
318 }
319 for _, g := range o.Groups {
320 if g.ID == mutation.GroupID {
321 return workspacestate.ErrMutationConflict
322 }
323 }
324 o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: mutation.GroupID, Title: strings.TrimSpace(mutation.Title), Members: []string{}})
325 case "rename-group", "delete-group":
326 if mutation.Kind == "rename-group" {
327 if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil {
328 return err
329 }
330 }
331 index := slices.IndexFunc(o.Groups, func(g workspacestate.OrganizationGroup) bool { return g.ID == mutation.GroupID })
332 if index < 0 {
333 return workspacestate.ErrMutationConflict
334 }
335 if mutation.Kind == "delete-group" {
336 o.Groups = slices.Delete(o.Groups, index, index+1)
337 } else {
338 if strings.TrimSpace(mutation.Title) == "" {
339 return fmt.Errorf("title required")
340 }
341 o.Groups[index].Title = strings.TrimSpace(mutation.Title)
342 }
343 default:
344 return fmt.Errorf("unsupported organization mutation")
345 }
346 return nil
347 }
348
349 func importOrganizationMembers(o *workspacestate.Organization, nodes []ProjectNode, groups []desktopGroup, canonicalByAlias map[string]string) {
350 for _, n := range nodes {
351 if n.Session == nil && n.SessionPath == "" {
352 continue
353 }
354 key := projectNodeSessionKey(n)
355 if _, adopted := canonicalByAlias[key]; adopted {
356 continue
357 }
358 if o.Imported[key] {
359 continue
360 }
361 for _, old := range groups {
362 included := desktopGroupContainsNode(old, n)
363 for _, alias := range n.IdentityAliases {
364 if slices.Contains(old.ExcludedSessionKeys, alias) {
365 included = false
366 break
367 }
368 if slices.Contains(old.SessionKeys, alias) {
369 included = true
370 }
371 }
372 if included {
373 for i := range o.Groups {
374 if o.Groups[i].ID == old.ID {
375 o.Groups[i].Members = append(o.Groups[i].Members, key)
376 break
377 }
378 }
379 }
380 }
381 if !slices.Contains(o.Order, key) {
382 o.Order = append(o.Order, key)
383 }
384 o.Imported[key] = true
385 }
386 }
387
388 func importOrganizationOrder(o *workspacestate.Organization, nodes []ProjectNode, order, topicOrder []string, canonicalByAlias map[string]string, manual bool) {
389 if manual && len(order) == 0 {
390 for _, topic := range topicOrder {
391 for _, node := range nodes {
392 if node.TopicID == topic {
393 order = append(order, projectNodeSessionKey(node))
394 }
395 }
396 }
397 }
398 if len(order) > 0 {
399 next := []string{}
400 for _, key := range order {
401 if canonical, ok := canonicalByAlias[key]; ok {
402 key = canonical
403 }
404 if o.Imported[key] && !slices.Contains(next, key) {
405 next = append(next, key)
406 }
407 }
408 for _, key := range o.Order {
409 if !slices.Contains(next, key) {
410 next = append(next, key)
411 }
412 }
413 o.Order = next
414 }
415 }
416
416 lines GO