返回 DeepSeek-Reasonix
session_source_rows.go
根目录 / desktop / session_source_rows.go
1 package main
2
3 import (
4 "os"
5 "reasonix/desktop/internal/workspacestate"
6 "reasonix/internal/agent"
7 "reasonix/internal/store"
8 "sync"
9 )
10
11 type sourceHeadObservation struct {
12 info os.FileInfo
13 index *agent.SessionHeadIndex
14 }
15
16 var sourceHeadRows sync.Map
17
18 // A mapped DAG head must not return as an unidentified path placeholder while
19 // its index is unavailable. This is a display filter, not an ownership alias:
20 // independently identified siblings remain eligible for their own rows.
21 // Adoption belongs to the source, not to the workspace a sidecar lists it in.
22 func adoptedSourceRows(state workspacestate.State) map[string]bool {
23 adopted := map[string]bool{}
24 for _, mapping := range state.SourceMappings {
25 for _, key := range state.SourceKeys(mapping.SourceKey) {
26 adopted["source\x00local\x00"+key] = true
27 }
28 adopted["source\x00local\x00"+desktopSourceKey(mapping.Path, "")] = true
29 if sourceMappingHasPathAlias(mapping) {
30 adopted[sessionRuntimeKey(mapping.Path)] = true
31 }
32 }
33 return adopted
34 }
35
36 // A single-head DAG is displayed by path, while upgrades record its head ID.
37 // Use the same path alias in every projection so retained originals cannot
38 // reappear after their canonical session is archived. Multi-head rows keep
39 // independent identities; adopting one must never hide its siblings.
40 func sourceMappingHasPathAlias(mapping workspacestate.SourceMapping) bool {
41 if mapping.HeadID == "" {
42 return true
43 }
44 heads, err := sessionSourceHeads(mapping.Path)
45 if err != nil {
46 return false
47 }
48 visible := 0
49 selected := false
50 for _, head := range heads {
51 if head.Retired {
52 continue
53 }
54 if head.Kind != agent.HeadKindConcurrent {
55 visible++
56 }
57 if head.Selected && head.ID == mapping.HeadID {
58 selected = true
59 }
60 }
61 return selected && visible <= 1
62 }
63
64 // Listing consumes only the published head index. Replaying an event log here
65 // would make sidebar pagination perform content work and contend with writers.
66 // Missing/stale indices degrade to one path row and are repaired separately.
67 func sessionSourceHeads(path string) ([]agent.SessionHead, error) {
68 if _, err := os.Stat(path); err != nil {
69 sourceHeadRows.Delete(path)
70 return nil, err
71 }
72 info, err := os.Stat(store.SessionEventIndex(path))
73 if err != nil {
74 sourceHeadRows.Delete(path)
75 if os.IsNotExist(err) {
76 return nil, nil
77 }
78 return nil, err
79 }
80 // The checkpoint, event log, and head index are published independently.
81 // Cache only the decoded index, keyed by that file's own identity, and
82 // validate its log coverage on every read. A transient missing/stale index
83 // must not survive publication merely because the checkpoint is unchanged.
84 var index *agent.SessionHeadIndex
85 if cached, ok := sourceHeadRows.Load(path); ok {
86 entry := cached.(sourceHeadObservation)
87 if os.SameFile(entry.info, info) && entry.info.Size() == info.Size() && entry.info.ModTime().Equal(info.ModTime()) {
88 index = entry.index
89 }
90 }
91 if index == nil {
92 index, err = agent.ReadSessionHeadIndex(path)
93 if err != nil || index == nil {
94 sourceHeadRows.Delete(path)
95 return nil, err
96 }
97 sourceHeadRows.Store(path, sourceHeadObservation{info: info, index: index})
98 }
99 if !index.Current(path) {
100 return nil, nil
101 }
102 return index.Heads, nil
103 }
104
105 func expandSessionSourceRows(node ProjectNode) []ProjectNode {
106 if node.Session != nil || node.SessionPath == "" || node.RecoveryState == "recovery_only" {
107 return []ProjectNode{node}
108 }
109 heads, err := sessionSourceHeads(node.SessionPath)
110 if err != nil {
111 node.Health = "degraded"
112 }
113 live := []agent.SessionHead{}
114 for _, head := range heads {
115 if !head.Retired && head.Kind != agent.HeadKindConcurrent {
116 live = append(live, head)
117 }
118 }
119 if len(live) <= 1 {
120 headID := ""
121 if len(live) == 1 {
122 headID = live[0].ID
123 }
124 node.Source = &SessionSourceRef{HostID: localDesktopHostID, Path: node.SessionPath, HeadID: headID, SourceKey: desktopSourceKey(node.SessionPath, headID)}
125 node.Historical = true
126 return []ProjectNode{node}
127 }
128 rows := []ProjectNode{}
129 for _, head := range live {
130 row := node
131 row.Source = &SessionSourceRef{HostID: localDesktopHostID, Path: node.SessionPath, HeadID: head.ID, SourceKey: desktopSourceKey(node.SessionPath, head.ID)}
132 row.Historical, row.HistoricalBranch = true, true
133 row.Key = "source_" + row.Source.SourceKey
134 row.Turns, row.Preview = head.Turns, head.Preview
135 if !head.LastActivity.IsZero() {
136 row.LastActivityAt = head.LastActivity.UnixMilli()
137 }
138 if !head.CreatedAt.IsZero() {
139 row.CreatedAt = head.CreatedAt.UnixMilli()
140 }
141 rows = append(rows, row)
142 }
143 return rows
144 }
145
145 lines GO