返回 DeepSeek-Reasonix
message_field.go
根目录 / internal / session / message_field.go
1 package session
2
3 import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "os"
10 "path/filepath"
11 "strings"
12 "unicode/utf8"
13
14 "reasonix/internal/projectiondb"
15 "reasonix/internal/sessioncontent"
16 )
17
18 // Per-field message body reading (history-window-v1 companion): a client
19 // requests one field of one message by stable identity — never a file path —
20 // and receives a bounded, UTF-8 aligned fragment with the full length and the
21 // next range offset. Fragments never split inside a JSON string escape
22 // sequence, so concatenated ranges re-parse as the original value.
23 const (
24 // MessageFieldMaxChunk bounds one response fragment: the plan's detail
25 // reading budget of at most 256 KiB per read.
26 MessageFieldMaxChunk = 256 << 10
27 // messageFieldMaxBody bounds the source message body a field read will
28 // decode; bodies above it must go through the streaming export path.
29 messageFieldMaxBody = sessioncontent.MaxReadRange
30 )
31
32 // MessageFieldPage is one bounded fragment of one message field.
33 type MessageFieldPage struct {
34 Status string `json:"status"` // ready | not_found | preparing | unsupported
35 MessageID string `json:"messageId"`
36 Version int `json:"version"`
37 Field string `json:"field"`
38 TotalBytes int64 `json:"totalBytes"`
39 Offset int64 `json:"offset"`
40 Data []byte `json:"data,omitempty"`
41 NextOffset int64 `json:"nextOffset,omitempty"`
42 Encoding string `json:"encoding"`
43 }
44
45 // ReadMessageField returns [offset, offset+length) of one top-level field of
46 // the canonical message JSON. The field "canonicalMessage" addresses the
47 // whole stored body. Version 0 selects the message's latest version inside
48 // the current durable snapshot. Range credentials come from a prior window
49 // or page that displayed the message; a read without one is rejected.
50 func (q *Query) ReadMessageField(ctx context.Context, ref SessionRef, messageID string, version int, field string, offset, length int64) (MessageFieldPage, error) {
51 if q == nil {
52 return MessageFieldPage{}, errors.New("session: nil query")
53 }
54 if err := ref.validate(q.hostID); err != nil {
55 return MessageFieldPage{}, err
56 }
57 messageID = strings.TrimSpace(messageID)
58 if messageID == "" || field == "" {
59 return MessageFieldPage{}, errors.New("session: message id and field are required")
60 }
61 if offset < 0 {
62 return MessageFieldPage{}, errors.New("session: invalid field offset")
63 }
64 if length <= 0 {
65 length = MessageFieldMaxChunk
66 }
67 length = min(length, MessageFieldMaxChunk)
68
69 filesystem, ok := q.persistence.(*FilesystemPersistence)
70 if !ok {
71 return MessageFieldPage{}, errors.New("session: field reads require filesystem persistence")
72 }
73 path := historyIndexPath(filesystem.Root, ref.SessionID)
74 if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
75 preparation := q.prepareHistoryLocator(filesystem, ref.SessionID, path)
76 select {
77 case <-preparation.done:
78 if preparation.err != nil {
79 return MessageFieldPage{Status: "not_found"}, preparation.err
80 }
81 default:
82 return MessageFieldPage{Status: "preparing", MessageID: messageID, Field: field}, nil
83 }
84 }
85 lock := q.projectionLock("history", ref.SessionID)
86 lock.Lock()
87 err := ensureHistoryIndex(ctx, filesystem, ref.SessionID, path)
88 lock.Unlock()
89 if err != nil {
90 return MessageFieldPage{}, err
91 }
92 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{Path: path, Migrations: historyMigrations, RequireDisk: true, MaxOpenConns: 1})
93 if err != nil {
94 return MessageFieldPage{}, err
95 }
96 defer handle.DB.Close()
97 metadata, err := readHistoryIndexMetadata(ctx, handle.DB)
98 if err != nil {
99 return MessageFieldPage{}, err
100 }
101 snapshot := metadata.durableSequence
102
103 page := MessageFieldPage{Status: "ready", MessageID: messageID, Version: version, Field: field, Offset: offset, Encoding: "utf-8"}
104 query := `SELECT inline,content_digest,content_bytes,content_index_digest FROM messages WHERE message_id=? AND event_sequence<=? AND (valid_to=0 OR valid_to>?)`
105 args := []any{messageID, snapshot, snapshot}
106 if version > 0 {
107 query += ` AND version=?`
108 args = append(args, version)
109 }
110 query += ` ORDER BY version DESC LIMIT 1`
111 var inline []byte
112 var digest, indexDigest string
113 var contentBytes int64
114 err = handle.DB.QueryRowContext(ctx, query, args...).Scan(&inline, &digest, &contentBytes, &indexDigest)
115 if errors.Is(err, sql.ErrNoRows) {
116 page.Status = "not_found"
117 return page, nil
118 }
119 if err != nil {
120 return MessageFieldPage{}, err
121 }
122 var body []byte
123 if digest != "" {
124 contentRef := sessioncontent.Ref{Digest: digest, Bytes: contentBytes, IndexDigest: indexDigest, IntegrityBlock: sessioncontent.IntegrityBlockBytes, MediaType: "application/json"}
125 if !q.contentAuthorized(ref.SessionID, digest, contentBytes, indexDigest) {
126 return MessageFieldPage{}, errors.New("session: content reference is not authorized for this session")
127 }
128 if contentBytes > messageFieldMaxBody {
129 return MessageFieldPage{}, errors.New("session: message body exceeds the field-read budget; use the streaming export path")
130 }
131 body, err = contentStoreForSessionDir(filepath.Join(filesystem.Root, ref.SessionID)).ReadRange(ctx, contentRef, 0, contentBytes)
132 if err != nil {
133 return MessageFieldPage{}, err
134 }
135 } else {
136 body = inline
137 }
138
139 fieldBytes, err := extractMessageField(body, field)
140 if errors.Is(err, errMessageFieldAbsent) {
141 // A message that simply does not carry the field is a finished empty
142 // read, not a failure: optional fields like reasoning are routinely
143 // absent, and a hard error would surface as a broken body.
144 return page, nil
145 }
146 if err != nil {
147 return MessageFieldPage{}, err
148 }
149 page.TotalBytes = int64(len(fieldBytes))
150 if offset >= page.TotalBytes {
151 return page, nil
152 }
153 end := min(offset+length, page.TotalBytes)
154 // Never split a UTF-8 rune: align the fragment end back to a rune start.
155 for end > offset && end < page.TotalBytes && !utf8.RuneStart(fieldBytes[end]) {
156 end--
157 }
158 // Never split a JSON escape sequence: a cut between a backslash and the
159 // character it escapes would corrupt concatenation. A cut between two
160 // complete escapes inside a string is fine.
161 if endsInsideEscape(fieldBytes, offset, end) {
162 end--
163 }
164 page.Data = append([]byte(nil), fieldBytes[offset:end]...)
165 page.Offset = offset
166 if end < page.TotalBytes {
167 page.NextOffset = end
168 }
169 return page, nil
170 }
171
172 // extractMessageField streams the canonical message JSON and captures the raw
173 // bytes of one top-level field value. Only the field itself is materialized —
174 // never the whole message.
175 func extractMessageField(body []byte, field string) ([]byte, error) {
176 if field == "canonicalMessage" {
177 return body, nil
178 }
179 decoder := json.NewDecoder(bytes.NewReader(body))
180 open, err := decoder.Token()
181 if err != nil {
182 return nil, err
183 }
184 if delim, ok := open.(json.Delim); !ok || delim != '{' {
185 return nil, errors.New("session: canonical message is not a JSON object")
186 }
187 for decoder.More() {
188 keyToken, err := decoder.Token()
189 if err != nil {
190 return nil, err
191 }
192 key, ok := keyToken.(string)
193 if !ok {
194 return nil, errors.New("session: canonical message has a non-string key")
195 }
196 var raw json.RawMessage
197 if err := decoder.Decode(&raw); err != nil {
198 return nil, err
199 }
200 if key == field {
201 return raw, nil
202 }
203 }
204 return nil, errMessageFieldAbsent
205 }
206
207 // errMessageFieldAbsent separates "this message has no such field" from a
208 // genuinely unreadable body: the caller answers the former with an empty
209 // finished read and the latter with an error.
210 var errMessageFieldAbsent = errors.New("session: message has no such field")
211
212 // endsInsideEscape reports whether a fragment ending at end would split a
213 // JSON escape sequence: an odd run of backslashes immediately before the cut
214 // means the last one dangles. begin is the fragment start, where a
215 // well-formed previous fragment guaranteed no dangling escape.
216 func endsInsideEscape(data []byte, begin, end int64) bool {
217 if end <= begin {
218 return false
219 }
220 backslashes := int64(0)
221 for i := end - 1; i >= begin; i-- {
222 if data[i] != '\\' {
223 break
224 }
225 backslashes++
226 }
227 return backslashes%2 == 1
228 }
229
229 lines GO