返回 DeepSeek-Reasonix
session_delete.go
根目录 / internal / serve / session_delete.go
1 package serve
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "log/slog"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/jobs"
16 "reasonix/internal/session"
17 "reasonix/internal/store"
18 )
19
20 var deleteSessionBeforeOwnershipLockHookForTest func()
21
22 // deleteSession removes a saved session by the session name returned from /sessions.
23 func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) {
24 var req struct {
25 Name string `json:"name"`
26 SessionID string `json:"sessionId"`
27 }
28 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
29 http.Error(w, "bad request", http.StatusBadRequest)
30 return
31 }
32 name := strings.TrimSpace(req.Name)
33 sessionID := strings.TrimSpace(req.SessionID)
34 if name == "" {
35 name = sessionID
36 }
37 if name == "" {
38 http.Error(w, "name required", http.StatusBadRequest)
39 return
40 }
41 // Validate the untrusted name before constructing any transcript or sidecar
42 // path. IsLocal also rejects Windows drive-relative and reserved names;
43 // the separator check keeps this endpoint restricted to one basename.
44 if !filepath.IsLocal(name) || name == "." || strings.ContainsAny(name, `/\`) {
45 http.Error(w, "invalid session name", http.StatusBadRequest)
46 return
47 }
48 // Serialize the ownership checks with session promotion: a detached
49 // controller leaves the background registry while it is promoted, and a
50 // delete crossing that window would remove a live controller's transcript.
51 if deleteSessionBeforeOwnershipLockHookForTest != nil {
52 deleteSessionBeforeOwnershipLockHookForTest()
53 }
54 s.bindMu.Lock()
55 defer s.bindMu.Unlock()
56 dir := s.ctl().SessionDir()
57 if sessionID != "" && s.canonicalSessionIsCurrent(sessionID) {
58 // Never let a colliding legacy basename take precedence over the active
59 // canonical identity.
60 s.deleteCanonicalSession(w, r, sessionID)
61 return
62 }
63 // A canonical session lives in <sessions-v4>/<sessionId>/, not
64 // <legacy-dir>/<name>.jsonl. Prefer an existing legacy file so the old
65 // endpoint stays compatible, then fall back to the immutable identity.
66 legacyExists := false
67 if dir != "" {
68 _, statErr := os.Stat(filepath.Join(dir, name+".jsonl"))
69 legacyExists = statErr == nil
70 }
71 if sessionID != "" && !legacyExists {
72 if s.deleteCanonicalSession(w, r, sessionID) {
73 return
74 }
75 }
76 if dir == "" {
77 http.Error(w, "sessions disabled", http.StatusBadRequest)
78 return
79 }
80 target := filepath.Join(dir, name+".jsonl")
81 abs, err := filepath.Abs(target)
82 if err != nil {
83 http.Error(w, "invalid session path", http.StatusBadRequest)
84 return
85 }
86 absDir, err := filepath.Abs(dir)
87 if err != nil {
88 http.Error(w, "invalid session dir", http.StatusBadRequest)
89 return
90 }
91 rel, err := filepath.Rel(absDir, abs)
92 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
93 http.Error(w, "path outside session dir", http.StatusForbidden)
94 return
95 }
96 if msg, status := s.legacyTranscriptDeleteRefusalLocked(abs); msg != "" {
97 http.Error(w, msg, status)
98 return
99 }
100 if err := s.destroyLegacyTranscriptLocked(absDir, abs); err != nil {
101 http.Error(w, err.Error(), http.StatusInternalServerError)
102 return
103 }
104 w.WriteHeader(http.StatusNoContent)
105 }
106
107 // legacyTranscriptDeleteRefusalLocked is the ownership gate every legacy
108 // transcript removal passes, whether the user named the transcript or it is
109 // the frozen source of a canonical row being deleted. Callers hold bindMu so a
110 // concurrent promotion cannot move the transcript between checks.
111 func (s *Server) legacyTranscriptDeleteRefusalLocked(abs string) (string, int) {
112 if filepath.Clean(abs) == filepath.Clean(s.ctl().SessionPath()) {
113 return "cannot delete active session", http.StatusConflict
114 }
115 if s.detachedBusy(filepath.Clean(abs)) {
116 return "session is running in the background; switch to it and stop the turn first", http.StatusConflict
117 }
118 if s.sessionMirrored(abs) {
119 // A local runtime is writing this transcript; deleting it here would
120 // pull the file out from under the writer.
121 return "session is taken over by a local Reasonix window", http.StatusConflict
122 }
123 return "", 0
124 }
125
126 // destroyLegacyTranscriptLocked tears down session-scoped jobs and removes the
127 // transcript with its sidecars. A teardown that outlives its grace period marks
128 // the transcript for delayed cleanup instead of leaving live jobs writing beside
129 // a half-removed session; the marker error is the only failure that still
130 // schedules the delayed removal.
131 func (s *Server) destroyLegacyTranscriptLocked(absDir, abs string) error {
132 destroy := s.ctl().BeginDestroySession(abs)
133 if result := finishSessionDestroy(destroy); result.HasTimedOut() {
134 err := agent.MarkCleanupPending(abs, "delete")
135 go delayedSessionDelete(absDir, abs, destroy)
136 return err
137 }
138 return removeSessionFiles(absDir, abs)
139 }
140
141 // deleteCanonicalSession deletes one canonical identity and reports whether
142 // the request was handled. It is called with bindMu held so a concurrent
143 // /resume or /new cannot change the foreground identity between the active
144 // check and the filesystem tombstone.
145 func (s *Server) deleteCanonicalSession(w http.ResponseWriter, r *http.Request, sessionID string) bool {
146 identity, ok := s.ctl().(control.IdentityLifecycle)
147 if !ok || !identity.UsesExclusiveSession() {
148 return false
149 }
150 service := identity.SessionService()
151 if service == nil {
152 http.Error(w, "canonical sessions disabled", http.StatusBadRequest)
153 return true
154 }
155 ref := session.SessionRef{HostID: service.HostID(), SessionID: sessionID}
156 if current, bound := identity.SessionRef(); bound && current == ref {
157 http.Error(w, "cannot delete active session", http.StatusConflict)
158 return true
159 }
160 // Resolve the frozen source before the row goes: the shared index counts
161 // live targets only. A refusal from the source's ownership gate stops the
162 // whole request rather than deleting the row and resurrecting the source.
163 source, hasSource, msg, status := s.migratedSourceForDeleteLocked(r.Context(), service, ref)
164 if msg != "" {
165 http.Error(w, msg, status)
166 return true
167 }
168 if err := service.Delete(r.Context(), ref); err != nil {
169 switch {
170 case errors.Is(err, session.ErrSessionNotFound):
171 http.Error(w, err.Error(), http.StatusNotFound)
172 case errors.Is(err, session.ErrRuntimeBusy), errors.Is(err, session.ErrRuntimeBound):
173 http.Error(w, err.Error(), http.StatusConflict)
174 default:
175 http.Error(w, err.Error(), http.StatusInternalServerError)
176 }
177 return true
178 }
179 if hasSource {
180 // Without its row the source resurfaces in /sessions as a fresh legacy
181 // row. A teardown failure leaves only that resurfacing, so it is
182 // reported instead of failing the delete the user asked for.
183 if err := s.destroyLegacyTranscriptLocked(filepath.Dir(source), source); err != nil {
184 slog.Warn("serve: remove migrated legacy source after canonical delete", "source", source, "err", err)
185 }
186 }
187 w.WriteHeader(http.StatusNoContent)
188 return true
189 }
190
191 // migratedSourceForDeleteLocked returns the legacy transcript that ref is the
192 // sole canonical target of, already validated as a deletable transcript inside
193 // the session dir. A source outside the session dir (or otherwise unresolvable)
194 // is left alone without blocking the canonical delete; a source that is active,
195 // running detached or mirrored returns the legacy delete's refusal. Callers
196 // hold bindMu.
197 func (s *Server) migratedSourceForDeleteLocked(ctx context.Context, service *session.Service, ref session.SessionRef) (source string, ok bool, refusal string, status int) {
198 dir, err := service.SessionDir(ctx, ref)
199 if err != nil {
200 return "", false, "", 0
201 }
202 index := loadMigrationIndex(map[string]struct{}{filepath.Dir(filepath.Clean(dir)): {}}, func(targetID string) bool {
203 _, statErr := service.SessionDir(ctx, session.SessionRef{HostID: ref.HostID, SessionID: targetID})
204 return statErr == nil
205 })
206 recorded, ok := index.byTarget[ref.SessionID]
207 if !ok {
208 return "", false, "", 0
209 }
210 realPath, err := s.resolveSessionPath(recorded)
211 if err != nil {
212 slog.Warn("serve: migrated legacy source is not a deletable transcript; leaving it", "source", recorded, "err", err)
213 return "", false, "", 0
214 }
215 abs := filepath.Clean(realPath)
216 if msg, code := s.legacyTranscriptDeleteRefusalLocked(abs); msg != "" {
217 return "", false, msg, code
218 }
219 return abs, true, "", 0
220 }
221
222 func (s *Server) canonicalSessionIsCurrent(sessionID string) bool {
223 identity, ok := s.ctl().(control.IdentityLifecycle)
224 if !ok || !identity.UsesExclusiveSession() {
225 return false
226 }
227 service := identity.SessionService()
228 if service == nil {
229 return false
230 }
231 current, bound := identity.SessionRef()
232 return bound && current == (session.SessionRef{HostID: service.HostID(), SessionID: sessionID})
233 }
234
235 func finishSessionDestroy(destroy control.SessionDestroyHandle) jobs.TeardownResult {
236 if destroy.Wait != nil {
237 result := destroy.Wait()
238 if destroy.Finish != nil && !result.HasTimedOut() {
239 destroy.Finish()
240 }
241 return result
242 }
243 if destroy.Finish != nil {
244 destroy.Finish()
245 }
246 return jobs.TeardownResult{}
247 }
248
249 func delayedSessionDelete(absDir, abs string, destroy control.SessionDestroyHandle) {
250 if destroy.WaitAll != nil {
251 destroy.WaitAll()
252 }
253 if err := removeSessionFiles(absDir, abs); err != nil {
254 slog.Warn("serve: delayed session delete failed", "path", abs, "err", err)
255 }
256 if destroy.Finish != nil {
257 destroy.Finish()
258 }
259 }
260
261 func removeSessionFiles(absDir, abs string) error {
262 remove := append([]string{abs}, store.SessionSidecarFiles(abs)...)
263 for _, p := range remove {
264 if p == "" {
265 continue
266 }
267 if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
268 return err
269 }
270 }
271 if err := agent.DeleteSubagentsByParent(absDir, agent.BranchID(abs)); err != nil {
272 return err
273 }
274 if err := jobs.RemoveArtifacts(abs); err != nil {
275 return err
276 }
277 return agent.ClearCleanupPending(abs)
278 }
279
279 lines GO