返回 DeepSeek-Reasonix
sessionstate.go
根目录 / internal / extension / sessionstate.go
1 package extension
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "sort"
10 "strings"
11
12 "reasonix/internal/fileutil"
13 )
14
15 // SessionExtensionsVersion is the on-disk version of the sidecar state file.
16 const SessionExtensionsVersion = 1
17
18 // SessionExtensionsFileSuffix is appended to the session JSONL path to form
19 // the per-session extension state path. The store NEVER reads or writes the
20 // session JSONL itself; old readers simply ignore this additive file.
21 const SessionExtensionsFileSuffix = ".extensions.json"
22
23 // MaxPluginStateBytes caps one plugin's state blob at 1 MiB. Writes beyond
24 // the cap fail — truncating would silently corrupt the extension's state.
25 const MaxPluginStateBytes = 1 << 20
26
27 // ErrPluginStateTooLarge rejects a per-plugin blob beyond MaxPluginStateBytes.
28 var ErrPluginStateTooLarge = errors.New("extension: plugin session state exceeds 1 MiB")
29
30 // sessionExtensionsFile is the JSON shape at
31 // <sessionPath>.extensions.json: a versioned map of per-plugin blobs.
32 type sessionExtensionsFile struct {
33 Version int `json:"version"`
34 Plugins map[string]json.RawMessage `json:"plugins"`
35 }
36
37 // SessionExtensions is the per-session state store extension sidecars use to
38 // persist their plugin-scoped state across reloads. It lives beside the
39 // session file at <sessionPath>.extensions.json and is deliberately separate:
40 // the session JSONL keeps its exact byte stability, and readers that predate
41 // extensions ignore the new file.
42 type SessionExtensions struct {
43 path string
44 plugins map[string]json.RawMessage
45 }
46
47 // SessionExtensionsPath returns the store path for one session file, or ""
48 // when the session has no path yet.
49 func SessionExtensionsPath(sessionPath string) string {
50 trimmed := strings.TrimSpace(sessionPath)
51 if trimmed == "" {
52 return ""
53 }
54 return trimmed + SessionExtensionsFileSuffix
55 }
56
57 // LoadSessionExtensions reads the store for sessionPath. A missing or
58 // corrupt file yields an empty store and no error — extension state must
59 // never crash a session load — and only I/O failures beyond those surface.
60 func LoadSessionExtensions(sessionPath string) (*SessionExtensions, error) {
61 store := &SessionExtensions{
62 path: SessionExtensionsPath(sessionPath),
63 plugins: make(map[string]json.RawMessage),
64 }
65 if store.path == "" {
66 return store, nil
67 }
68 data, err := os.ReadFile(store.path)
69 if err != nil {
70 if errors.Is(err, os.ErrNotExist) {
71 return store, nil
72 }
73 return nil, fmt.Errorf("extension: load session extensions: %w", err)
74 }
75 var file sessionExtensionsFile
76 dec := json.NewDecoder(bytes.NewReader(data))
77 dec.DisallowUnknownFields()
78 if err := dec.Decode(&file); err != nil {
79 // Corrupt state must not fail the session: start empty. The next
80 // Save atomically replaces the bad file.
81 return store, nil
82 }
83 for pluginID, blob := range file.Plugins {
84 if len(blob) > MaxPluginStateBytes {
85 // An over-cap blob is dropped, not truncated: partial JSON is
86 // worse than none.
87 continue
88 }
89 store.plugins[pluginID] = append(json.RawMessage(nil), blob...)
90 }
91 return store, nil
92 }
93
94 // Path returns the store's file path.
95 func (s *SessionExtensions) Path() string { return s.path }
96
97 // Get returns the stored blob for pluginID.
98 func (s *SessionExtensions) Get(pluginID string) (json.RawMessage, bool) {
99 blob, ok := s.plugins[pluginID]
100 if !ok {
101 return nil, false
102 }
103 return append(json.RawMessage(nil), blob...), true
104 }
105
106 // Set stages pluginID's blob for the next Save. The blob must be valid JSON
107 // within the 1 MiB cap; over-cap writes fail with ErrPluginStateTooLarge and
108 // are never truncated.
109 func (s *SessionExtensions) Set(pluginID string, blob json.RawMessage) error {
110 if strings.TrimSpace(pluginID) == "" {
111 return errors.New("extension: plugin ID is required for session state")
112 }
113 if len(blob) > MaxPluginStateBytes {
114 return fmt.Errorf("%w (plugin %q: %d bytes)", ErrPluginStateTooLarge, pluginID, len(blob))
115 }
116 trimmed := bytes.TrimSpace(blob)
117 if len(trimmed) == 0 || !json.Valid(trimmed) {
118 return fmt.Errorf("extension: plugin %q session state must be valid JSON", pluginID)
119 }
120 s.plugins[pluginID] = append(json.RawMessage(nil), trimmed...)
121 return nil
122 }
123
124 // Delete removes pluginID's blob. A missing ID is a no-op.
125 func (s *SessionExtensions) Delete(pluginID string) {
126 delete(s.plugins, pluginID)
127 }
128
129 // Plugins returns the IDs with staged state, sorted for determinism.
130 func (s *SessionExtensions) Plugins() []string {
131 out := make([]string, 0, len(s.plugins))
132 for pluginID := range s.plugins {
133 out = append(out, pluginID)
134 }
135 sort.Strings(out)
136 return out
137 }
138
139 // Save atomically persists the staged blobs (tmpfile + rename via
140 // fileutil.AtomicWriteFile), so a crash mid-save never leaves a half-written
141 // store. It writes only the .extensions.json sibling — never the session
142 // JSONL itself.
143 func (s *SessionExtensions) Save() error {
144 if s.path == "" {
145 return errors.New("extension: session extensions store has no path")
146 }
147 file := sessionExtensionsFile{Version: SessionExtensionsVersion, Plugins: s.plugins}
148 data, err := json.Marshal(file)
149 if err != nil {
150 return err
151 }
152 data = append(data, '\n')
153 return fileutil.AtomicWriteFile(s.path, data, 0o644)
154 }
155
155 lines GO