| 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 |