返回 DeepSeek-Reasonix
transcript_api.go
根目录 / internal / serve / transcript_api.go
1 package serve
2
3 import (
4 "encoding/base64"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "strings"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/control"
13 "reasonix/internal/session"
14 "reasonix/internal/sessioncontent"
15 "reasonix/internal/transcript"
16 )
17
18 func (s *Server) registerTranscriptRoutes(mux *http.ServeMux) {
19 mux.HandleFunc("GET /session-export/snapshot", s.sessionExportSnapshot)
20 mux.HandleFunc("POST /session-export/document", s.sessionExportDocument)
21 mux.HandleFunc("POST /session-export/validate", s.sessionExportValidate)
22 mux.HandleFunc("POST /session-export/diagnostic", s.sessionExportDiagnostic)
23 mux.HandleFunc("GET /transcript/follow", s.transcriptFollow)
24 mux.HandleFunc("GET /transcript/snapshot", s.transcriptSnapshot)
25 mux.HandleFunc("GET /transcript/page", s.transcriptSnapshot)
26 mux.HandleFunc("GET /transcript/content", s.transcriptContent)
27 mux.HandleFunc("GET /transcript/outline", s.transcriptOutline)
28 mux.HandleFunc("GET /transcript/replay", s.transcriptReplay)
29 mux.HandleFunc("GET /session/open", s.sessionOpen)
30 mux.HandleFunc("GET /session-history/page", s.sessionHistoryPage)
31 mux.HandleFunc("GET /session-history/search", s.sessionHistorySearch)
32 mux.HandleFunc("GET /session-history/locate", s.sessionHistoryLocate)
33 mux.HandleFunc("GET /session-history/content", s.sessionHistoryContent)
34 mux.HandleFunc("GET /session-history/window", s.sessionHistoryWindow)
35 mux.HandleFunc("GET /session-message-field", s.sessionMessageField)
36 }
37
38 // sessionHistoryWindow serves history-window-v1: a bounded window around an
39 // anchor in either direction of a fixed durable snapshot.
40 func (s *Server) sessionHistoryWindow(w http.ResponseWriter, r *http.Request) {
41 s.bindMu.Lock()
42 defer s.bindMu.Unlock()
43 query, ref, ok := s.canonicalSessionQuery(w, r)
44 if !ok {
45 return
46 }
47 req := session.HistoryWindowRequest{Anchor: r.URL.Query().Get("anchor"), MessageID: r.URL.Query().Get("messageId"), Cursor: r.URL.Query().Get("cursor"), Direction: r.URL.Query().Get("direction")}
48 if raw := r.URL.Query().Get("turn"); raw != "" {
49 if _, err := fmt.Sscan(raw, &req.Turn); err != nil {
50 http.Error(w, "invalid history window turn", http.StatusBadRequest)
51 return
52 }
53 }
54 if raw := r.URL.Query().Get("limit"); raw != "" {
55 if _, err := fmt.Sscan(raw, &req.Limit); err != nil {
56 http.Error(w, "invalid history window limit", http.StatusBadRequest)
57 return
58 }
59 }
60 page, err := query.ReadHistoryWindow(r.Context(), ref, req)
61 if err != nil {
62 http.Error(w, err.Error(), http.StatusConflict)
63 return
64 }
65 w.Header().Set("Cache-Control", "no-store")
66 w.Header().Set("Content-Type", "application/json")
67 _ = json.NewEncoder(w).Encode(page)
68 }
69
70 // sessionMessageField serves one bounded, UTF-8 aligned fragment of one
71 // top-level message field.
72 func (s *Server) sessionMessageField(w http.ResponseWriter, r *http.Request) {
73 s.bindMu.Lock()
74 defer s.bindMu.Unlock()
75 query, ref, ok := s.canonicalSessionQuery(w, r)
76 if !ok {
77 return
78 }
79 q := r.URL.Query()
80 var version int
81 var offset, length int64
82 if raw := q.Get("version"); raw != "" {
83 if _, err := fmt.Sscan(raw, &version); err != nil {
84 http.Error(w, "invalid message field version", http.StatusBadRequest)
85 return
86 }
87 }
88 if raw := q.Get("offset"); raw != "" {
89 if _, err := fmt.Sscan(raw, &offset); err != nil {
90 http.Error(w, "invalid message field offset", http.StatusBadRequest)
91 return
92 }
93 }
94 if raw := q.Get("length"); raw != "" {
95 if _, err := fmt.Sscan(raw, &length); err != nil {
96 http.Error(w, "invalid message field length", http.StatusBadRequest)
97 return
98 }
99 }
100 page, err := query.ReadMessageField(r.Context(), ref, q.Get("messageId"), version, q.Get("field"), offset, length)
101 if err != nil {
102 http.Error(w, err.Error(), http.StatusConflict)
103 return
104 }
105 w.Header().Set("Cache-Control", "no-store")
106 w.Header().Set("Content-Type", "application/json")
107 _ = json.NewEncoder(w).Encode(page)
108 }
109
110 func (s *Server) sessionOpen(w http.ResponseWriter, r *http.Request) {
111 s.bindMu.Lock()
112 defer s.bindMu.Unlock()
113 query, ref, ok := s.canonicalSessionQuery(w, r)
114 if !ok {
115 return
116 }
117 view, err := query.OpenSession(r.Context(), ref)
118 if err != nil {
119 http.Error(w, err.Error(), http.StatusConflict)
120 return
121 }
122 w.Header().Set("Cache-Control", "no-store")
123 w.Header().Set("Content-Type", "application/json")
124 _ = json.NewEncoder(w).Encode(view)
125 }
126
127 type sessionHistoryContentRequest struct {
128 Ref sessioncontent.Ref `json:"ref"`
129 Offset int64 `json:"offset"`
130 Length int64 `json:"length"`
131 }
132
133 type sessionHistoryContentResponse struct {
134 Data string `json:"data"`
135 NextOffset int64 `json:"nextOffset"`
136 Done bool `json:"done"`
137 }
138
139 func (s *Server) canonicalSessionQuery(w http.ResponseWriter, r *http.Request) (*session.Query, session.SessionRef, bool) {
140 identity, ok := s.ctl().(control.IdentityLifecycle)
141 if !ok || !identity.UsesExclusiveSession() {
142 http.Error(w, "canonical session history is unavailable", http.StatusNotImplemented)
143 return nil, session.SessionRef{}, false
144 }
145 service := identity.SessionService()
146 if service == nil || service.Query() == nil {
147 http.Error(w, "canonical session identity is unavailable", http.StatusConflict)
148 return nil, session.SessionRef{}, false
149 }
150 ref, bound := identity.SessionRef()
151 requested := strings.TrimSpace(r.URL.Query().Get("sessionId"))
152 if requested == "" || (bound && requested == ref.SessionID) {
153 if !bound {
154 http.Error(w, "canonical session identity is unavailable", http.StatusConflict)
155 return nil, session.SessionRef{}, false
156 }
157 return service.Query(), ref, true
158 }
159 // A remote tab renders its persisted first page before POST /resume
160 // activates the session, and a spectator reads history the foreground no
161 // longer owns: both are cold reads the store answers without a runtime.
162 requested = strings.TrimPrefix(requested, remoteSessionIDQueryPrefix)
163 cold := session.SessionRef{HostID: service.HostID(), SessionID: requested}
164 if _, err := service.Query().Stat(r.Context(), cold); err != nil {
165 http.Error(w, "session history is not bound to this runtime", http.StatusConflict)
166 return nil, session.SessionRef{}, false
167 }
168 return service.Query(), cold, true
169 }
170
171 func (s *Server) sessionHistoryPage(w http.ResponseWriter, r *http.Request) {
172 s.bindMu.Lock()
173 defer s.bindMu.Unlock()
174 query, ref, ok := s.canonicalSessionQuery(w, r)
175 if !ok {
176 return
177 }
178 limit := 0
179 if raw := r.URL.Query().Get("limit"); raw != "" {
180 if _, err := fmt.Sscan(raw, &limit); err != nil {
181 http.Error(w, "invalid history limit", http.StatusBadRequest)
182 return
183 }
184 }
185 page, err := query.HistoryPage(r.Context(), ref, r.URL.Query().Get("cursor"), limit)
186 if err != nil {
187 http.Error(w, err.Error(), http.StatusConflict)
188 return
189 }
190 w.Header().Set("Cache-Control", "no-store")
191 w.Header().Set("Content-Type", "application/json")
192 _ = json.NewEncoder(w).Encode(page)
193 }
194
195 func (s *Server) sessionHistoryContent(w http.ResponseWriter, r *http.Request) {
196 var request sessionHistoryContentRequest
197 if !transcriptRequest(w, r, &request) {
198 return
199 }
200 if request.Length <= 0 || request.Length > 1<<20 {
201 http.Error(w, "invalid content range", http.StatusBadRequest)
202 return
203 }
204 s.bindMu.Lock()
205 defer s.bindMu.Unlock()
206 query, ref, ok := s.canonicalSessionQuery(w, r)
207 if !ok {
208 return
209 }
210 data, err := query.ReadContent(r.Context(), ref, request.Ref, request.Offset, request.Length)
211 if err != nil {
212 http.Error(w, err.Error(), http.StatusConflict)
213 return
214 }
215 next := request.Offset + int64(len(data))
216 w.Header().Set("Cache-Control", "no-store")
217 w.Header().Set("Content-Type", "application/json")
218 _ = json.NewEncoder(w).Encode(sessionHistoryContentResponse{Data: base64.StdEncoding.EncodeToString(data), NextOffset: next, Done: next == request.Ref.Bytes})
219 }
220
221 func (s *Server) sessionHistorySearch(w http.ResponseWriter, r *http.Request) {
222 s.bindMu.Lock()
223 defer s.bindMu.Unlock()
224 query, ref, ok := s.canonicalSessionQuery(w, r)
225 if !ok {
226 return
227 }
228 textQuery := r.URL.Query().Get("q")
229 if len(textQuery) > 4096 {
230 http.Error(w, "history search query is too large", http.StatusBadRequest)
231 return
232 }
233 limit := 0
234 if raw := r.URL.Query().Get("limit"); raw != "" {
235 if _, err := fmt.Sscan(raw, &limit); err != nil {
236 http.Error(w, "invalid history search limit", http.StatusBadRequest)
237 return
238 }
239 }
240 page, err := query.SearchHistory(r.Context(), ref, textQuery, r.URL.Query().Get("cursor"), limit)
241 if err != nil {
242 http.Error(w, err.Error(), http.StatusConflict)
243 return
244 }
245 w.Header().Set("Cache-Control", "no-store")
246 w.Header().Set("Content-Type", "application/json")
247 _ = json.NewEncoder(w).Encode(page)
248 }
249
250 func (s *Server) sessionHistoryLocate(w http.ResponseWriter, r *http.Request) {
251 s.bindMu.Lock()
252 defer s.bindMu.Unlock()
253 query, ref, ok := s.canonicalSessionQuery(w, r)
254 if !ok {
255 return
256 }
257 var snapshot uint64
258 if raw := r.URL.Query().Get("snapshot"); raw != "" {
259 if _, err := fmt.Sscan(raw, &snapshot); err != nil {
260 http.Error(w, "invalid history snapshot", http.StatusBadRequest)
261 return
262 }
263 }
264 location, err := query.LocateMessage(r.Context(), ref, r.URL.Query().Get("messageId"), snapshot)
265 if err != nil {
266 http.Error(w, err.Error(), http.StatusConflict)
267 return
268 }
269 w.Header().Set("Cache-Control", "no-store")
270 w.Header().Set("Content-Type", "application/json")
271 _ = json.NewEncoder(w).Encode(location)
272 }
273
274 // errTranscriptCapabilityMissing lets a read decline an optional capability
275 // without colliding with a genuine read failure, which must stay a conflict.
276 var errTranscriptCapabilityMissing = errors.New("transcript capability is missing")
277
278 // transcriptRead binds each read to the controller that owns the referenced
279 // session: the foreground when the reference is absent or matches it, else
280 // the detached session holding that identity or path. A file mirror cannot
281 // claim a live event cursor and explicitly declines this protocol.
282 func (s *Server) transcriptRead(w http.ResponseWriter, r *http.Request, read func(control.TranscriptProjectionAPI) (any, error)) {
283 s.transcriptBoundRead(w, r, func(ctrl control.SessionAPI) (any, error) {
284 api, ok := ctrl.(control.TranscriptProjectionAPI)
285 if !ok {
286 return nil, errTranscriptCapabilityMissing
287 }
288 return read(api)
289 })
290 }
291
292 // transcriptBoundRead resolves the selected controller, enforces the session
293 // binding every transcript read shares, and encodes one JSON response. An
294 // unimplemented optional capability is reported as not implemented rather than
295 // silently answered with an empty page.
296 func (s *Server) transcriptBoundRead(w http.ResponseWriter, r *http.Request, read func(control.SessionAPI) (any, error)) {
297 s.bindMu.Lock()
298 raw := strings.TrimSpace(r.URL.Query().Get("session"))
299 if raw != "" && !strings.HasPrefix(raw, remoteSessionIDQueryPrefix) {
300 if resolved, err := s.resolveSessionPath(raw); err == nil && s.sessionMirrored(agent.CanonicalSessionPath(resolved)) {
301 s.bindMu.Unlock()
302 http.Error(w, "transcript projection is unavailable", http.StatusNotImplemented)
303 return
304 }
305 }
306 ctrl := s.resolveReadControllerLocked(raw)
307 if ctrl == nil {
308 s.bindMu.Unlock()
309 http.Error(w, "transcript session is not bound to this runtime", http.StatusConflict)
310 return
311 }
312 if ctrl == s.ctl() {
313 if path := agent.CanonicalSessionPath(ctrl.SessionPath()); path != "" && s.sessionMirrored(path) {
314 s.bindMu.Unlock()
315 http.Error(w, "transcript projection is unavailable", http.StatusNotImplemented)
316 return
317 }
318 }
319 s.bindMu.Unlock()
320 value, err := read(ctrl)
321 s.bindMu.Lock()
322 // A detached or identity-routed read cannot be verified by a foreground
323 // path comparison; re-resolving the same reference and comparing the
324 // controller covers every routing case.
325 current := s.resolveReadControllerLocked(raw) == ctrl
326 s.bindMu.Unlock()
327 if !current {
328 http.Error(w, "transcript runtime changed during read", http.StatusConflict)
329 return
330 }
331 if errors.Is(err, errTranscriptCapabilityMissing) {
332 http.Error(w, "transcript projection is unavailable", http.StatusNotImplemented)
333 return
334 }
335 if err != nil {
336 http.Error(w, err.Error(), http.StatusConflict)
337 return
338 }
339 w.Header().Set("Cache-Control", "no-store")
340 w.Header().Set("Content-Type", "application/json")
341 _ = json.NewEncoder(w).Encode(value)
342 }
343
344 // resolveReadControllerLocked returns the controller a transcript read
345 // targets. An empty reference selects the foreground; an identity or path
346 // reference selects the foreground when it matches, otherwise the detached
347 // session holding it. bindMu must be held; detachedMu nests inside it.
348 func (s *Server) resolveReadControllerLocked(raw string) control.SessionAPI {
349 foreground := s.ctl()
350 if raw == "" {
351 return foreground
352 }
353 if id, ok := strings.CutPrefix(raw, remoteSessionIDQueryPrefix); ok {
354 if controllerBoundToIdentity(foreground, id) {
355 return foreground
356 }
357 s.detachedMu.Lock()
358 defer s.detachedMu.Unlock()
359 for _, detached := range s.detached {
360 if controllerBoundToIdentity(detached.ctrl, id) {
361 return detached.ctrl
362 }
363 }
364 return nil
365 }
366 path := agent.CanonicalSessionPath(raw)
367 if resolved, err := s.resolveSessionPath(raw); err == nil {
368 path = agent.CanonicalSessionPath(resolved)
369 }
370 if agent.CanonicalSessionPath(foreground.SessionPath()) == path {
371 return foreground
372 }
373 s.detachedMu.Lock()
374 defer s.detachedMu.Unlock()
375 for _, detached := range s.detached {
376 if agent.CanonicalSessionPath(detached.ctrl.SessionPath()) == path {
377 return detached.ctrl
378 }
379 }
380 return nil
381 }
382
383 // remoteSessionIDQueryPrefix marks a session reference as an identity ID
384 // rather than a legacy transcript path; it matches the desktop's routing
385 // prefix for exclusive identity sessions.
386 const remoteSessionIDQueryPrefix = "session-id:"
387
388 // controllerBoundToIdentity reports whether ctrl currently runs the exclusive
389 // identity session the caller referenced.
390 func controllerBoundToIdentity(ctrl control.SessionAPI, id string) bool {
391 ref, ok := ctrl.(interface {
392 SessionRef() (session.SessionRef, bool)
393 })
394 if !ok {
395 return false
396 }
397 bound, has := ref.SessionRef()
398 return has && bound.SessionID == id
399 }
400
401 func (s *Server) transcriptFollow(w http.ResponseWriter, r *http.Request) {
402 var req transcript.FollowRequest
403 if !transcriptRequest(w, r, &req) {
404 return
405 }
406 s.transcriptBoundRead(w, r, func(ctrl control.SessionAPI) (any, error) {
407 api, ok := ctrl.(control.TranscriptFollowAPI)
408 if !ok {
409 return nil, errTranscriptCapabilityMissing
410 }
411 return api.TranscriptFollow(r.Context(), req)
412 })
413 }
414
415 func transcriptRequest(w http.ResponseWriter, r *http.Request, dst any) bool {
416 encoded := r.URL.Query().Get("request")
417 if encoded == "" {
418 return true
419 }
420 if len(encoded) > 8192 || json.Unmarshal([]byte(encoded), dst) != nil {
421 http.Error(w, "invalid transcript request", http.StatusBadRequest)
422 return false
423 }
424 return true
425 }
426
427 func (s *Server) transcriptSnapshot(w http.ResponseWriter, r *http.Request) {
428 var req transcript.PageRequest
429 if !transcriptRequest(w, r, &req) {
430 return
431 }
432 s.transcriptRead(w, r, func(api control.TranscriptProjectionAPI) (any, error) { return api.TranscriptSnapshot(req) })
433 }
434
435 func (s *Server) transcriptContent(w http.ResponseWriter, r *http.Request) {
436 var req transcript.ContentRequest
437 if !transcriptRequest(w, r, &req) {
438 return
439 }
440 s.transcriptRead(w, r, func(api control.TranscriptProjectionAPI) (any, error) { return api.TranscriptContent(req) })
441 }
442
443 func (s *Server) transcriptOutline(w http.ResponseWriter, r *http.Request) {
444 var req transcript.OutlineRequest
445 if !transcriptRequest(w, r, &req) {
446 return
447 }
448 s.transcriptBoundRead(w, r, func(ctrl control.SessionAPI) (any, error) {
449 api, ok := ctrl.(control.TranscriptOutlineAPI)
450 if !ok {
451 return nil, errTranscriptCapabilityMissing
452 }
453 return api.TranscriptOutline(req)
454 })
455 }
456
457 func (s *Server) transcriptReplay(w http.ResponseWriter, r *http.Request) {
458 var req control.TranscriptReplayRequest
459 if !transcriptRequest(w, r, &req) {
460 return
461 }
462 s.transcriptRead(w, r, func(api control.TranscriptProjectionAPI) (any, error) { return api.TranscriptReplay(req) })
463 }
464
464 lines GO