返回 DeepSeek-Reasonix
fork_session_http.go
根目录 / internal / serve / fork_session_http.go
1 package serve
2
3 import (
4 "encoding/json"
5 "errors"
6 "log/slog"
7 "net/http"
8 "strings"
9
10 "reasonix/internal/control"
11 "reasonix/internal/session"
12 )
13
14 // forkSessionBodyMax bounds the /fork-session body: turn id, title, and
15 // operation id are short strings, so nothing legitimate approaches this limit.
16 const forkSessionBodyMax = 8 << 10
17
18 // forkTargetsResponse is GET /fork-targets' payload. Targets is a JSON array on
19 // every path, including the empty one: a client renders [] as "nothing to fork
20 // from", and null would break its list rendering.
21 type forkTargetsResponse struct {
22 Source session.SessionRef `json:"source"`
23 Targets []session.ForkTarget `json:"targets"`
24 Verifiable bool `json:"verifiable"`
25 }
26
27 // forkSessionResponse identifies the child POST /fork-session created. The
28 // parent keeps its own identity, so this child id is not the serve session id
29 // the X-Reasonix-Session-ID response header reports and is never echoed there.
30 type forkSessionResponse struct {
31 HostID string `json:"hostId,omitempty"`
32 SessionID string `json:"sessionId"`
33 TurnID string `json:"turnId"`
34 TurnNumber int `json:"turnNumber"`
35 }
36
37 type forkErrorResponse struct {
38 Code string `json:"code"`
39 Reason session.ForkAvailability `json:"reason,omitempty"`
40 Message string `json:"message"`
41 }
42
43 func writeForkError(w http.ResponseWriter, status int, reason session.ForkAvailability, message string) {
44 w.Header().Set("Content-Type", "application/json")
45 w.WriteHeader(status)
46 _ = json.NewEncoder(w).Encode(forkErrorResponse{Code: "fork_unavailable", Reason: reason, Message: message})
47 }
48
49 func (s *Server) requireForkSessionFenceLocked(w http.ResponseWriter, r *http.Request) bool {
50 identity, ok := s.ctl().(control.IdentityLifecycle)
51 if !ok || !identity.UsesExclusiveSession() {
52 return true
53 }
54 if strings.TrimSpace(r.Header.Get(expectedSessionIDHeader)) == "" && strings.TrimSpace(r.Header.Get(expectedSessionPathHeader)) == "" {
55 writeForkError(w, http.StatusBadRequest, session.ForkStaleSource, "expected session header is required")
56 return false
57 }
58 return true
59 }
60
61 // registerForkRoutes mounts the fork reads and the parent-preserving child
62 // creation.
63 func (s *Server) registerForkRoutes(mux *http.ServeMux) {
64 mux.HandleFunc("GET /fork-targets", s.forkTargets)
65 mux.HandleFunc("POST /fork-session", s.forkSession)
66 }
67
68 // forkTargets lists the turns of the current serve session a client may fork
69 // from, each with the reason it is refused when it is not forkable. The list is
70 // derived from the session's durable commits, so it is the same list every
71 // client computes and it stays readable while a turn is running.
72 func (s *Server) forkTargets(w http.ResponseWriter, r *http.Request) {
73 s.bindMu.Lock()
74 if !s.requireForkSessionFenceLocked(w, r) {
75 s.bindMu.Unlock()
76 return
77 }
78 if err := s.expectedSessionErrorLocked(r); err != nil {
79 writeForkError(w, http.StatusConflict, session.ForkStaleSource, err.Error())
80 s.bindMu.Unlock()
81 return
82 }
83 ref, service, ok := s.forkSourceLocked(w)
84 s.bindMu.Unlock()
85 if !ok {
86 return
87 }
88 // A legacy session keeps messages without turn records, so it proves no
89 // boundary: the empty, unverifiable set is the honest answer.
90 if service == nil {
91 writeJSON(w, forkTargetsResponse{Source: ref, Targets: []session.ForkTarget{}})
92 return
93 }
94 // The read runs unlocked: it walks the session log, and holding bindMu for
95 // that would stall /resume and /fork behind one client's transcript.
96 set, err := service.ForkTargetSetFor(r.Context(), ref)
97 if err != nil {
98 http.Error(w, err.Error(), http.StatusConflict)
99 return
100 }
101 if set.Targets == nil {
102 set.Targets = []session.ForkTarget{}
103 }
104 writeJSON(w, forkTargetsResponse{Source: set.Source, Targets: set.Targets, Verifiable: set.Verifiable})
105 }
106
107 // forkSession creates an independent child session from one completed turn of
108 // the current serve session. Unlike POST /fork it never switches the parent:
109 // the controller, the session lease, and the broadcast binding stay where they
110 // are, so a running parent keeps running and keeps its remote viewers. The cut
111 // is resolved from the source's persisted turn records and must match the
112 // source identity and atomic boundary the client observed.
113 func (s *Server) forkSession(w http.ResponseWriter, r *http.Request) {
114 var body struct {
115 SourceSessionID string `json:"sourceSessionId"`
116 TurnID string `json:"turnId"`
117 BoundarySequence uint64 `json:"boundarySequence"`
118 Name string `json:"name"`
119 OperationID string `json:"operationId"`
120 }
121 r.Body = http.MaxBytesReader(w, r.Body, forkSessionBodyMax)
122 if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.SourceSessionID) == "" ||
123 strings.TrimSpace(body.TurnID) == "" || body.BoundarySequence == 0 || strings.TrimSpace(body.OperationID) == "" {
124 writeForkError(w, http.StatusBadRequest, "", "sourceSessionId, turnId, boundarySequence, and operationId are required")
125 return
126 }
127 // Validate and resolve the source under bindMu, then create unlocked: the
128 // fork copies the parent's durable log, and holding the binding lock for it
129 // would block /resume, /new, and /fork for the whole copy.
130 s.bindMu.Lock()
131 if !s.requireForkSessionFenceLocked(w, r) {
132 s.bindMu.Unlock()
133 return
134 }
135 if err := s.expectedSessionErrorLocked(r); err != nil {
136 writeForkError(w, http.StatusConflict, session.ForkStaleSource, err.Error())
137 s.bindMu.Unlock()
138 return
139 }
140 // A mirrored foreground is owned by a local writer, so Serve's copy of it is
141 // not the transcript a child may inherit (the same refusal as POST /fork).
142 if s.foregroundMirroredLocked() {
143 writeForkError(w, http.StatusConflict, session.ForkActiveAuthority, errSessionTakenOver)
144 s.bindMu.Unlock()
145 return
146 }
147 ref, service, ok := s.forkSourceLocked(w)
148 if ok && service != nil && ref.SessionID != strings.TrimSpace(body.SourceSessionID) {
149 writeForkError(w, http.StatusConflict, session.ForkStaleSource, "fork source session changed")
150 ok = false
151 }
152 s.bindMu.Unlock()
153 if !ok {
154 return
155 }
156 if service == nil {
157 writeForkError(w, http.StatusNotImplemented, session.ForkUnsupported, "session forks are unavailable")
158 return
159 }
160 result, err := service.CreateFork(r.Context(), session.ForkRequest{
161 Source: ref, TurnID: strings.TrimSpace(body.TurnID), BoundarySequence: body.BoundarySequence,
162 OperationID: strings.TrimSpace(body.OperationID),
163 })
164 if err != nil {
165 var unavailable *session.ForkUnavailableError
166 if errors.As(err, &unavailable) {
167 writeForkError(w, http.StatusConflict, unavailable.Reason, unavailable.Error())
168 return
169 }
170 http.Error(w, err.Error(), http.StatusInternalServerError)
171 return
172 }
173 if title := strings.TrimSpace(body.Name); title != "" {
174 // The child is already durable; a title that fails to record is a
175 // presentation loss, not a failed creation.
176 if err := service.SetTitle(r.Context(), result.Child, title); err != nil {
177 slog.Warn("serve: fork child title", "session", result.Child.SessionID, "err", err)
178 }
179 }
180 writeJSON(w, forkSessionResponse{
181 HostID: result.Child.HostID, SessionID: result.Child.SessionID,
182 TurnID: result.Turn.TurnID, TurnNumber: result.Turn.TurnNumber,
183 })
184 }
185
186 // forkSourceLocked resolves the identity session a fork reads from and the
187 // service that owns it, from one publication epoch. Callers hold bindMu. A nil
188 // service is a legacy session, which keeps no turn records and so no verifiable
189 // boundary; ok is false once a refusal has been written.
190 func (s *Server) forkSourceLocked(w http.ResponseWriter) (session.SessionRef, *session.Service, bool) {
191 identity, ok := s.ctl().(control.IdentityLifecycle)
192 if !ok || !identity.UsesExclusiveSession() {
193 return session.SessionRef{}, nil, true
194 }
195 ref, bound := identity.SessionRef()
196 service := identity.SessionService()
197 if !bound || service == nil || service.Query() == nil {
198 writeForkError(w, http.StatusConflict, session.ForkStaleSource, "canonical session identity is unavailable")
199 return session.SessionRef{}, nil, false
200 }
201 return ref, service, true
202 }
203
203 lines GO