| 1 | package serve |
| 2 | |
| 3 | import ( |
| 4 | "log/slog" |
| 5 | "net/http" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/control" |
| 12 | "reasonix/internal/store" |
| 13 | ) |
| 14 | |
| 15 | // status returns a combined status snapshot. The desktop's runtime-only path |
| 16 | // skips provider balance IO while retaining all reconciliation fields. |
| 17 | func (s *Server) status(w http.ResponseWriter, r *http.Request) { |
| 18 | identityRoute, answered := s.statusIdentityRoute(w, r) |
| 19 | if answered { |
| 20 | return |
| 21 | } |
| 22 | if s.writeSpectatorStatus(w, r) { |
| 23 | return |
| 24 | } |
| 25 | s.writeForegroundStatus(w, r, identityRoute) |
| 26 | } |
| 27 | |
| 28 | // statusIdentityRoute answers a spectator watching a final-format identity a |
| 29 | // local runtime owns. A foreground-bound identity falls through, but the |
| 30 | // selector is remembered so the snapshot can state ownership explicitly: a |
| 31 | // spectator pin must clear the moment this serve owns the identity again. |
| 32 | func (s *Server) statusIdentityRoute(w http.ResponseWriter, r *http.Request) (route string, answered bool) { |
| 33 | raw := r.URL.Query().Get("session") |
| 34 | if !isSessionIDRoute(raw) { |
| 35 | return "", false |
| 36 | } |
| 37 | if s.statusIdentityOverride(w, raw) { |
| 38 | return "", true |
| 39 | } |
| 40 | return strings.TrimSpace(raw), false |
| 41 | } |
| 42 | |
| 43 | // writeSpectatorStatus answers an explicitly selected legacy session with the |
| 44 | // file-backed read-only view instead of the foreground controller's. |
| 45 | func (s *Server) writeSpectatorStatus(w http.ResponseWriter, r *http.Request) bool { |
| 46 | raw := r.URL.Query().Get("session") |
| 47 | if raw == "" { |
| 48 | return false |
| 49 | } |
| 50 | path, err := s.resolveSessionPath(raw) |
| 51 | if err != nil { |
| 52 | return false |
| 53 | } |
| 54 | held := s.sessionMirrored(path) || leaseHeldByForeignRuntime(path) |
| 55 | if !held { |
| 56 | if view, ok := s.ownedRuntimeStatusView(path); ok { |
| 57 | writeJSON(w, view) |
| 58 | return true |
| 59 | } |
| 60 | } |
| 61 | writeJSON(w, s.statusViewForPath(path, held)) |
| 62 | if s.sessionMirrored(path) { |
| 63 | s.maybeAutoReclaimMirrored(path) |
| 64 | } |
| 65 | return true |
| 66 | } |
| 67 | |
| 68 | func (s *Server) writeForegroundStatus(w http.ResponseWriter, r *http.Request, identityRoute string) { |
| 69 | // Session rotations publish the controller path and executor Session while |
| 70 | // holding bindMu. Read the combined snapshot in that same binding epoch so |
| 71 | // callers can never pair a newly published path with the outgoing history. |
| 72 | s.bindMu.Lock() |
| 73 | ctrl := s.ctl() |
| 74 | sess := sessionStatusFields(ctrl) |
| 75 | sessionPath := strings.TrimSpace(ctrl.SessionPath()) |
| 76 | if identity, ok := ctrl.(control.IdentityLifecycle); ok { |
| 77 | if ref, bound := identity.SessionRef(); bound { |
| 78 | sess["hostId"] = ref.HostID |
| 79 | sess["sessionId"] = ref.SessionID |
| 80 | } |
| 81 | } |
| 82 | if identityRoute != "" { |
| 83 | // The poller selected an identity explicitly: answer ownership both |
| 84 | // ways. Without an explicit false after a reclaim, clients that only |
| 85 | // apply present fields keep a stale spectator pin forever. |
| 86 | sess["takenOver"] = s.sessionMirrored(identityRoute) |
| 87 | } |
| 88 | if sessionPath != "" && store.IsSessionTranscriptName(filepath.Base(sessionPath)) { |
| 89 | sess["sessionName"] = strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl") |
| 90 | sess["sessionPath"] = agent.CanonicalSessionPath(sessionPath) |
| 91 | } |
| 92 | addEffortStatus(sess, ctrl) |
| 93 | if canonical := agent.CanonicalSessionPath(sessionPath); canonical != "" && s.sessionMirrored(canonical) { |
| 94 | s.applyMirroredStatus(sess, canonical) |
| 95 | s.bindMu.Unlock() |
| 96 | s.maybeAutoReclaimMirrored(canonical) |
| 97 | writeJSON(w, sess) |
| 98 | return |
| 99 | } |
| 100 | if u := ctrl.LastUsage(); u != nil { |
| 101 | sess["lastUsage"] = u |
| 102 | } |
| 103 | sess["sessionCostQuote"] = s.bc.SessionCostQuoteFor(agent.CanonicalSessionPath(sessionPath)) |
| 104 | if j := ctrl.Jobs(); len(j) > 0 { |
| 105 | sess["jobs"] = j |
| 106 | } |
| 107 | // Balance can perform provider IO and does not participate in session |
| 108 | // identity. Release the binding epoch before that optional slow request. |
| 109 | s.bindMu.Unlock() |
| 110 | if r.URL.Query().Get("runtime") != "1" && r.URL.Query().Get("lite") != "1" { |
| 111 | s.addBalanceStatus(r, sess, ctrl) |
| 112 | } |
| 113 | writeJSON(w, sess) |
| 114 | } |
| 115 | |
| 116 | func sessionStatusFields(ctrl control.SessionAPI) map[string]any { |
| 117 | used, window := ctrl.ContextSnapshot() |
| 118 | hit, miss := ctrl.SessionCache() |
| 119 | state, rs := runtimeStateAndStatus(ctrl) |
| 120 | sess := map[string]any{ |
| 121 | "runtimeState": state, |
| 122 | "label": ctrl.Label(), |
| 123 | "running": rs.Running, |
| 124 | "plan": ctrl.PlanMode(), |
| 125 | "autoApproveTools": ctrl.AutoApproveTools(), |
| 126 | "bypass": ctrl.AutoApproveTools(), |
| 127 | "toolApprovalMode": ctrl.ToolApprovalMode(), |
| 128 | "goal": ctrl.Goal(), |
| 129 | "goalStatus": ctrl.GoalStatus(), |
| 130 | "qualityFloor": ctrl.QualityFloor(), |
| 131 | "cwd": ctrl.SessionDir(), |
| 132 | "used": used, |
| 133 | "window": window, |
| 134 | "cacheHit": hit, |
| 135 | "cacheMiss": miss, |
| 136 | // Runtime reconciliation fields for desktop running-state watchdogs: |
| 137 | // the remote tab surface polls /status and maps these onto the same |
| 138 | // reconciliation the local tabs get from ListTabs. |
| 139 | "pendingPrompt": rs.PendingPrompt, |
| 140 | "backgroundJobs": rs.BackgroundJobs, |
| 141 | "cancelRequested": rs.CancelRequested, |
| 142 | "cancellable": rs.Cancellable, |
| 143 | } |
| 144 | if reader, ok := ctrl.(control.RuntimeStateReader); ok { |
| 145 | sess["goalView"] = reader.RuntimeStateSnapshot().Goal |
| 146 | } |
| 147 | if ctrl.Goal() != "" { |
| 148 | sess["goalRuntime"] = ctrl.GoalRuntime() |
| 149 | } |
| 150 | return sess |
| 151 | } |
| 152 | |
| 153 | func addEffortStatus(sess map[string]any, ctrl control.SessionAPI) { |
| 154 | cfg, err := config.Load() |
| 155 | if err != nil { |
| 156 | return |
| 157 | } |
| 158 | entry, ok := cfg.ResolveModel(currentModelRef(ctrl)) |
| 159 | if !ok { |
| 160 | return |
| 161 | } |
| 162 | capability := config.EffortCapabilityForEntry(entry) |
| 163 | levels := capability.Levels |
| 164 | if levels == nil { |
| 165 | levels = []string{} |
| 166 | } |
| 167 | sess["effort"] = map[string]any{ |
| 168 | "supported": capability.Supported, |
| 169 | "current": config.EffortDisplay(entry), |
| 170 | "default": capability.Default, |
| 171 | "levels": levels, |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // applyMirroredStatus states that a local runtime owns the session. These |
| 176 | // fields are the authoritative ownership signal: a slow subscriber can drop |
| 177 | // notices, the status poll cannot. |
| 178 | func (s *Server) applyMirroredStatus(sess map[string]any, canonical string) { |
| 179 | sess["running"] = false |
| 180 | sess["pendingPrompt"] = false |
| 181 | sess["takenOver"] = true |
| 182 | if m, ok := s.mirroredEntry(canonical); ok { |
| 183 | sess["reclaimRequested"] = m.reclaimRequested |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func (s *Server) addBalanceStatus(r *http.Request, sess map[string]any, ctrl control.SessionAPI) { |
| 188 | b, err := ctrl.Balance(r.Context()) |
| 189 | if err != nil { |
| 190 | slog.Warn("serve: balance fetch failed", "err", err) |
| 191 | return |
| 192 | } |
| 193 | if b == nil { |
| 194 | return |
| 195 | } |
| 196 | if cfg, loadErr := config.Load(); loadErr == nil && cfg.DisplayCurrencyPref() == "" { |
| 197 | // Runtime-only hint: a single wallet currency may select an existing |
| 198 | // valuation, but is never persisted as configuration or history. |
| 199 | s.bc.SetDisplayCurrency(b.PrimaryCurrency()) |
| 200 | } |
| 201 | sess["balance"] = map[string]any{ |
| 202 | "display": b.Display(), |
| 203 | "available": b.Available, |
| 204 | "infos": b.Infos, |
| 205 | } |
| 206 | } |
| 207 |