返回 DeepSeek-Reasonix
pinned_context_store.go
根目录 / desktop / pinned_context_store.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "os"
10 "sort"
11 "strings"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/fileutil"
15 "reasonix/internal/store"
16 )
17
18 const (
19 pinnedContextSchemaVersion = 1
20 maxPinnedContextStateBytes = 64 * 1024
21 )
22
23 type pinnedContextState struct {
24 SchemaVersion int `json:"schemaVersion"`
25 SessionID string `json:"sessionId"`
26 Files []string `json:"files"`
27 }
28
29 func emptyPinnedContextState(sessionPath string) pinnedContextState {
30 return pinnedContextState{
31 SchemaVersion: pinnedContextSchemaVersion,
32 SessionID: agent.BranchID(sessionPath),
33 Files: []string{},
34 }
35 }
36
37 func normalizePinnedContextFiles(files []string) ([]string, error) {
38 out := make([]string, 0, len(files))
39 seen := make(map[string]struct{}, len(files))
40 for _, path := range files {
41 clean, err := normalizePinnedRelPath(path)
42 if err != nil {
43 return nil, fmt.Errorf("invalid pinned path %q: %w", path, err)
44 }
45 if _, ok := seen[clean]; ok {
46 continue
47 }
48 seen[clean] = struct{}{}
49 out = append(out, clean)
50 }
51 if len(out) > maxPinnedFileCount {
52 return nil, fmt.Errorf("at most %d files can be pinned", maxPinnedFileCount)
53 }
54 sort.Strings(out)
55 return out, nil
56 }
57
58 func loadPinnedContextState(sessionPath string) (pinnedContextState, error) {
59 sessionPath = strings.TrimSpace(sessionPath)
60 state := emptyPinnedContextState(sessionPath)
61 if sessionPath == "" {
62 return state, nil
63 }
64 path := store.SessionPinnedContext(sessionPath)
65 file, err := os.Open(path)
66 if errors.Is(err, os.ErrNotExist) {
67 return state, nil
68 }
69 if err != nil {
70 return state, fmt.Errorf("read pinned context state: %w", err)
71 }
72 defer file.Close()
73 info, err := file.Stat()
74 if err != nil {
75 return state, fmt.Errorf("stat pinned context state: %w", err)
76 }
77 if info.Size() > maxPinnedContextStateBytes {
78 return state, fmt.Errorf("pinned context state exceeds %d bytes", maxPinnedContextStateBytes)
79 }
80 raw, err := io.ReadAll(io.LimitReader(file, maxPinnedContextStateBytes+1))
81 if err != nil {
82 return state, fmt.Errorf("read pinned context state: %w", err)
83 }
84 if len(raw) > maxPinnedContextStateBytes {
85 return state, fmt.Errorf("pinned context state exceeds %d bytes", maxPinnedContextStateBytes)
86 }
87 if err := json.Unmarshal(raw, &state); err != nil {
88 return emptyPinnedContextState(sessionPath), fmt.Errorf("decode pinned context state: %w", err)
89 }
90 if state.SchemaVersion != pinnedContextSchemaVersion {
91 return emptyPinnedContextState(sessionPath), fmt.Errorf("unsupported pinned context schema version %d", state.SchemaVersion)
92 }
93 wantID := agent.BranchID(sessionPath)
94 if state.SessionID != wantID {
95 return emptyPinnedContextState(sessionPath), fmt.Errorf("pinned context belongs to session %q, not %q", state.SessionID, wantID)
96 }
97 files, err := normalizePinnedContextFiles(state.Files)
98 if err != nil {
99 return emptyPinnedContextState(sessionPath), err
100 }
101 state.Files = files
102 return state, nil
103 }
104
105 func savePinnedContextState(sessionPath string, files []string) error {
106 sessionPath = strings.TrimSpace(sessionPath)
107 if sessionPath == "" {
108 return fmt.Errorf("session is not ready")
109 }
110 normalized, err := normalizePinnedContextFiles(files)
111 if err != nil {
112 return err
113 }
114 state := emptyPinnedContextState(sessionPath)
115 state.Files = normalized
116 raw, err := json.Marshal(state)
117 if err != nil {
118 return err
119 }
120 raw = append(raw, '\n')
121 if len(raw) > maxPinnedContextStateBytes {
122 return fmt.Errorf("pinned context state exceeds %d bytes", maxPinnedContextStateBytes)
123 }
124 return fileutil.AtomicWriteFileStrict(store.SessionPinnedContext(sessionPath), raw, 0o600)
125 }
126
127 func loadOrMigratePinnedContextState(sessionPath string, legacy []string) (pinnedContextState, error) {
128 if strings.TrimSpace(sessionPath) == "" {
129 state := emptyPinnedContextState("")
130 files, err := normalizePinnedContextFiles(legacy)
131 state.Files = files
132 return state, err
133 }
134 state, err := loadPinnedContextState(sessionPath)
135 if err != nil || len(state.Files) > 0 || len(legacy) == 0 {
136 return state, err
137 }
138 if _, statErr := os.Stat(store.SessionPinnedContext(sessionPath)); statErr == nil {
139 return state, nil
140 } else if !errors.Is(statErr, os.ErrNotExist) {
141 return state, statErr
142 }
143 files, err := normalizePinnedContextFiles(legacy)
144 if err != nil {
145 return state, err
146 }
147 if err := savePinnedContextState(sessionPath, files); err != nil {
148 return state, err
149 }
150 state.Files = files
151 return state, nil
152 }
153
154 func copyPinnedContextState(sourcePath, targetPath string) error {
155 if strings.TrimSpace(sourcePath) == "" || strings.TrimSpace(targetPath) == "" {
156 return nil
157 }
158 if _, err := os.Stat(store.SessionPinnedContext(sourcePath)); errors.Is(err, os.ErrNotExist) {
159 return savePinnedContextState(targetPath, []string{})
160 } else if err != nil {
161 return err
162 }
163 state, err := loadPinnedContextState(sourcePath)
164 if err != nil {
165 return err
166 }
167 return savePinnedContextState(targetPath, state.Files)
168 }
169
170 func loadPinnedContextStateOrEmpty(sessionPath, logMessage string) pinnedContextState {
171 state, err := loadPinnedContextState(sessionPath)
172 if err == nil {
173 return state
174 }
175 slog.Warn(logMessage, "session", agent.BranchID(sessionPath), "err", err)
176 return emptyPinnedContextState(sessionPath)
177 }
178
179 func prepareStartupPinnedContext(tab *WorkspaceTab, startupPath, persistedPath string) {
180 if startupPath != "" {
181 migratePendingLegacyPinnedFiles(tab, startupPath)
182 if len(tab.pendingLegacyPinnedFilesForPersistence()) > 0 {
183 return
184 }
185 state := loadPinnedContextStateOrEmpty(startupPath, "desktop: load startup pinned context")
186 tab.setPinnedFiles(state.Files)
187 } else if strings.TrimSpace(persistedPath) != "" {
188 // A rejected persisted path must not seed its replacement. A pathless
189 // legacy entry keeps its cache until the one-time migration runs.
190 tab.setPinnedFiles(nil)
191 }
192 }
193
194 func restoreTabPinnedContext(tab *WorkspaceTab, legacy []string) {
195 // Canonical identities and rejected locators must never enter the legacy
196 // sidecar migration. Keep upgrade input until a verified binding owns it.
197 if tab.SessionID != "" {
198 tab.retainLegacyPinnedFiles(legacy)
199 return
200 }
201 path := ""
202 if tab.SessionPath != "" {
203 validated, ok := validatedLegacySessionPathForRead(tab.SessionPath)
204 if !ok {
205 tab.retainLegacyPinnedFiles(legacy)
206 return
207 }
208 path = string(validated)
209 }
210 state, err := loadOrMigratePinnedContextState(path, legacy)
211 if err != nil {
212 tab.retainLegacyPinnedFiles(legacy)
213 slog.Warn("desktop: restore pinned context", "err", err)
214 return
215 }
216 if strings.TrimSpace(tab.SessionPath) == "" && len(legacy) > 0 {
217 tab.setPinnedFilesState(state.Files, legacy)
218 return
219 }
220 tab.setPinnedFiles(state.Files)
221 }
222
223 func migratePendingLegacyPinnedFiles(tab *WorkspaceTab, sessionPath string) {
224 legacy := tab.pendingLegacyPinnedFilesForPersistence()
225 if len(legacy) == 0 || strings.TrimSpace(sessionPath) == "" {
226 return
227 }
228 sidecar := store.SessionPinnedContext(sessionPath)
229 if _, err := os.Stat(sidecar); err == nil {
230 if _, loadErr := loadPinnedContextState(sessionPath); loadErr == nil {
231 tab.clearPendingLegacyPinnedFiles()
232 }
233 return
234 } else if !errors.Is(err, os.ErrNotExist) {
235 return
236 }
237 if err := savePinnedContextState(sessionPath, legacy); err != nil {
238 slog.Warn("desktop: migrate pending legacy pinned context", "err", err)
239 return
240 }
241 tab.clearPendingLegacyPinnedFiles()
242 }
243
244 func pinnedContextStateForSessionBinding(tab *WorkspaceTab, sessionPath string) (pinnedContextState, bool) {
245 pendingLegacy := tab.pendingLegacyPinnedFilesForPersistence()
246 _, sidecarErr := os.Stat(store.SessionPinnedContext(sessionPath))
247 state, err := loadPinnedContextState(sessionPath)
248 if err != nil {
249 slog.Warn("desktop: load session pinned context", "session", agent.BranchID(sessionPath), "err", err)
250 }
251 preserveLegacy := len(pendingLegacy) > 0 && (errors.Is(sidecarErr, os.ErrNotExist) || err != nil)
252 return state, preserveLegacy
253 }
254
255 func applyPinnedContextSessionBinding(tab *WorkspaceTab, state pinnedContextState, preserveLegacy bool) {
256 if !preserveLegacy {
257 tab.setPinnedFiles(state.Files)
258 }
259 }
260
260 lines GO