返回 DeepSeek-Reasonix
remote_tab_snapshot.go
根目录 / desktop / remote_tab_snapshot.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/event"
17 )
18
19 // errRemoteTabStatusSuperseded marks the benign lost race where a /status
20 // response lands after SSE-derived runtime state already advanced the tab
21 // revision. Adoption was correctly skipped; callers (watchdog, close policy)
22 // merely skip the snapshot instead of surfacing a crash.
23 var errRemoteTabStatusSuperseded = errors.New("status was superseded by newer runtime state")
24
25 type RemoteTabSnapshot struct {
26 History json.RawMessage `json:"history"`
27 Context json.RawMessage `json:"context,omitempty"`
28 Todos json.RawMessage `json:"todos,omitempty"`
29 Checkpoints json.RawMessage `json:"checkpoints,omitempty"`
30 Models json.RawMessage `json:"models,omitempty"`
31 Commands json.RawMessage `json:"commands,omitempty"`
32 Status json.RawMessage `json:"status,omitempty"`
33 PendingEvents []json.RawMessage `json:"pendingEvents,omitempty"`
34 }
35
36 // sanitizeRemoteHistory keeps older Serve builds from leaking provider-only
37 // transient blocks into the desktop transcript.
38 func sanitizeRemoteHistory(body []byte) []byte {
39 var rows []map[string]json.RawMessage
40 if json.Unmarshal(body, &rows) != nil {
41 return body
42 }
43 changed := false
44 for _, row := range rows {
45 var role, content string
46 if json.Unmarshal(row["role"], &role) != nil || role != "user" || json.Unmarshal(row["content"], &content) != nil {
47 continue
48 }
49 clean := agent.UserPreviewText(content)
50 if clean == content {
51 continue
52 }
53 encoded, err := json.Marshal(clean)
54 if err == nil {
55 row["content"] = encoded
56 changed = true
57 }
58 }
59 if !changed {
60 return body
61 }
62 var out bytes.Buffer
63 encoder := json.NewEncoder(&out)
64 encoder.SetEscapeHTML(false)
65 if encoder.Encode(rows) != nil {
66 return body
67 }
68 return bytes.TrimSpace(out.Bytes())
69 }
70
71 // RemoteTabSnapshot merges the serve's GET members in parallel. Only
72 // /history is required; the optional members degrade to absent on failure.
73 func (a *App) RemoteTabSnapshot(tabID string) (RemoteTabSnapshot, error) {
74 return a.remoteTabSnapshot(tabID, true)
75 }
76
77 // RemoteTabMetadata loads ancillary state without transferring transcript bodies.
78 func (a *App) RemoteTabMetadata(tabID string) (RemoteTabSnapshot, error) {
79 return a.remoteTabSnapshot(tabID, false)
80 }
81
82 func (a *App) remoteTabSnapshot(tabID string, includeHistory bool) (RemoteTabSnapshot, error) {
83 client, base, err := a.remoteTabCommandClient(tabID)
84 if err != nil {
85 return RemoteTabSnapshot{}, err
86 }
87 gen := a.remoteTabClientGeneration(tabID, client)
88 statusSeq := a.reserveRemoteTabStatusSequence(tabID, client, gen)
89 ctx, cancel := commandContext(a)
90 defer cancel()
91 var snap RemoteTabSnapshot
92 var wg sync.WaitGroup
93 var mu sync.Mutex
94 var historyErr error
95 // A spectator pinned to a taken-over session reads the mirrored file view
96 // for history and status; the other members stay on the foreground.
97 sessionQuery := ""
98 a.remoteTabMu.Lock()
99 if tab := a.remoteTabs[tabID]; tab != nil && tab.session.takenOver && strings.TrimSpace(tab.routing.currentPath) != "" {
100 sessionQuery = "?session=" + url.QueryEscape(tab.routing.currentPath)
101 }
102 a.remoteTabMu.Unlock()
103 for path, dst := range map[string]*json.RawMessage{
104 "/history": &snap.History,
105 "/context": &snap.Context,
106 "/todos": &snap.Todos,
107 "/checkpoints": &snap.Checkpoints,
108 "/models": &snap.Models,
109 "/commands": &snap.Commands,
110 "/status": &snap.Status,
111 } {
112 if path == "/history" && !includeHistory {
113 continue
114 }
115 wg.Add(1)
116 go func(path string, dst *json.RawMessage) {
117 defer wg.Done()
118 switch path {
119 case "/history", "/status":
120 path += sessionQuery
121 }
122 data, err := serveGet(ctx, client, serveURL(base, path))
123 mu.Lock()
124 defer mu.Unlock()
125 if err != nil {
126 if path == "/history" && historyErr == nil {
127 historyErr = err
128 }
129 return
130 }
131 *dst = data
132 }(path, dst)
133 }
134 wg.Wait()
135 if historyErr != nil {
136 return RemoteTabSnapshot{}, historyErr
137 }
138 if includeHistory && len(snap.History) == 0 {
139 return RemoteTabSnapshot{}, fmt.Errorf("remote tab %q: empty history", tabID)
140 }
141 snap.History = sanitizeRemoteHistory(snap.History)
142 if len(snap.Status) > 0 && !a.recordRemoteTabSessionStatus(tabID, client, gen, statusSeq, snap.Status) {
143 // Do not hand a status member captured before a newer request/event to
144 // the frontend aggregate snapshot; it will fetch /status explicitly.
145 snap.Status = nil
146 }
147 a.remoteTabMu.Lock()
148 tab := a.remoteTabs[tabID]
149 if tab == nil || tab.client != client || tab.gen != gen || tab.state != "ready" {
150 a.remoteTabMu.Unlock()
151 return RemoteTabSnapshot{}, fmt.Errorf("remote tab %q changed while loading snapshot", tabID)
152 }
153 keys := make([]string, 0, len(tab.pendingEvents))
154 for key := range tab.pendingEvents {
155 keys = append(keys, key)
156 }
157 sort.Strings(keys)
158 for _, key := range keys {
159 snap.PendingEvents = append(snap.PendingEvents, append(json.RawMessage(nil), tab.pendingEvents[key]...))
160 }
161 a.remoteTabMu.Unlock()
162 a.recordRemoteTabModelCatalog(tabID, client, gen, snap.Models)
163 return snap, nil
164 }
165
166 // RemoteTabStatus is the small status-only binding used by watchdog and close
167 // policy polling. It deliberately avoids transferring full history.
168 func (a *App) RemoteTabStatus(tabID string) (json.RawMessage, error) {
169 client, base, err := a.remoteTabCommandClient(tabID)
170 if err != nil {
171 return nil, err
172 }
173 gen := a.remoteTabClientGeneration(tabID, client)
174 statusSeq := a.reserveRemoteTabStatusSequence(tabID, client, gen)
175 ctx, cancel := commandContext(a)
176 defer cancel()
177 status, err := serveGet(ctx, client, serveURL(base, a.remoteTabStatusURL(tabID)))
178 if err == nil {
179 if !a.recordRemoteTabSessionStatus(tabID, client, gen, statusSeq, status) {
180 return nil, fmt.Errorf("remote tab %q %w", tabID, errRemoteTabStatusSuperseded)
181 }
182 a.refreshRemoteModelOwnership(ctx, tabID, client, gen)
183 }
184 return status, err
185 }
186
187 // remoteTabStatusURL selects the status endpoint for a tab. A spectator
188 // pinned to a taken-over session asks for that session's mirrored view; the
189 // serve's foreground belongs to whatever else it runs.
190 func (a *App) remoteTabStatusURL(tabID string) string {
191 path := "/status?runtime=1"
192 a.remoteTabMu.Lock()
193 tab := a.remoteTabs[tabID]
194 if tab != nil && tab.session.takenOver && strings.TrimSpace(tab.routing.currentPath) != "" {
195 path += "&session=" + url.QueryEscape(tab.routing.currentPath)
196 }
197 a.remoteTabMu.Unlock()
198 return path
199 }
200
201 func (a *App) remoteTabClientGeneration(tabID string, client *http.Client) uint64 {
202 a.remoteTabMu.Lock()
203 defer a.remoteTabMu.Unlock()
204 if tab := a.remoteTabs[tabID]; tab != nil && tab.client == client {
205 return tab.gen
206 }
207 return 0
208 }
209
210 func (a *App) reserveRemoteTabStatusSequence(tabID string, client *http.Client, gen uint64) uint64 {
211 if gen == 0 {
212 return 0
213 }
214 a.remoteTabMu.Lock()
215 defer a.remoteTabMu.Unlock()
216 tab := a.remoteTabs[tabID]
217 if tab == nil || tab.client != client || tab.gen != gen {
218 return 0
219 }
220 tab.runtime.revision++
221 return tab.runtime.revision
222 }
223
224 type remoteTabStatusPayload struct {
225 RuntimeState *event.RuntimeStateSnapshot `json:"runtimeState"`
226 SessionName string `json:"sessionName"`
227 SessionPath string `json:"sessionPath"`
228 SessionID string `json:"sessionId"`
229 Running *bool `json:"running"`
230 PendingPrompt *bool `json:"pendingPrompt"`
231 BackgroundJobs *int `json:"backgroundJobs"`
232 CancelRequested *bool `json:"cancelRequested"`
233 Cancellable *bool `json:"cancellable"`
234 // TakenOver reports Serve's single-writer handoff state: a local runtime
235 // on the serve host owns the session and this tab is read-only.
236 TakenOver *bool `json:"takenOver"`
237 }
238
239 func (a *App) recordRemoteTabSessionStatus(tabID string, client *http.Client, gen, statusSeq uint64, status json.RawMessage) bool {
240 var payload remoteTabStatusPayload
241 if gen == 0 || statusSeq == 0 {
242 return false
243 }
244 decodeErr := json.Unmarshal(status, &payload)
245 a.remoteTabMu.Lock()
246 tab := a.remoteTabs[tabID]
247 a.remoteTabMu.Unlock()
248 if tab == nil {
249 return false
250 }
251 tab.routeEventMu.Lock()
252 defer tab.routeEventMu.Unlock()
253 a.remoteTabMu.Lock()
254 if a.remoteTabs[tabID] != tab || tab.client != client || tab.gen != gen || tab.runtime.revision != statusSeq {
255 a.remoteTabMu.Unlock()
256 return false
257 }
258 if decodeErr != nil {
259 markRemoteRuntimeUnknownLocked(tab, tab.routing.currentPath)
260 a.remoteTabMu.Unlock()
261 a.emitRuntimeStateChanged()
262 a.goRemoteTabSafe("remoteRuntimeSync", func() { _, _ = a.SyncRuntimeState() })
263 return false
264 }
265 payloadRoute := remoteSessionIdentityRoute(payload.SessionPath, payload.SessionID)
266 if remoteTabStatusRouteRejectedLocked(tab, payloadRoute) {
267 a.remoteTabMu.Unlock()
268 return false
269 }
270 // A payload reserved before an explicit reclaim can still be in flight with
271 // the pre-reclaim ownership; drop only its takenOver=true so its remaining
272 // runtime facts stay usable without re-pinning the spectator banner.
273 if statusSeq < tab.ownership.reclaimRevision && payload.TakenOver != nil && *payload.TakenOver {
274 payload.TakenOver = nil
275 }
276 before := remoteTabMetaLocked(tab)
277 pathChanged := adoptRemoteTabSessionPathLocked(tab, payloadRoute)
278 if pathChanged {
279 tab.topicTitle = remoteWorkspaceName(tab.ref.Workspace)
280 }
281 applyRemoteTabStatusPayload(tab, payload)
282 after := remoteTabMetaLocked(tab)
283 readyBarrier, deferredBarrier := resolveRemoteTabOwnershipBarrierLocked(tab, before, after,
284 remoteTabReadyBarrier(tab, pathChanged))
285 a.remoteTabMu.Unlock()
286 if remoteTabStatusMetaChanged(before, after) {
287 a.emitRemoteEvent("remote-tab:updated", after)
288 }
289 if readyBarrier || deferredBarrier {
290 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"})
291 }
292 if pathChanged {
293 a.goRemoteTabSafe("remoteTabStatusTitle", func() { a.refreshRemoteTabTitle(tabID) })
294 }
295 a.emitRuntimeStateChanged()
296 return true
297 }
298
299 // remoteTabStatusRouteRejectedLocked reports whether a /status payload names a
300 // session this tab must not follow.
301 //
302 // Serve still reports the outgoing foreground until an in-flight /resume
303 // commits: that status is older than the provisional route and must not roll
304 // it back, and the target's SSE frames are already buffering behind its ready
305 // barrier. A spectator watches the session it explicitly selected, so the
306 // foreground status of any other session must not re-route its tab.
307 func remoteTabStatusRouteRejectedLocked(tab *remoteTab, payloadRoute string) bool {
308 if pendingPath := tab.routing.rehydratingPath; pendingPath != "" && payloadRoute != "" && payloadRoute != pendingPath {
309 return true
310 }
311 return payloadRoute != "" && payloadRoute != tab.routing.currentPath && tab.session.takenOver
312 }
313
314 // resolveRemoteTabOwnershipBarrierLocked decides whether this status refresh
315 // publishes the re-hydration barrier, given the barrier a route change already
316 // requires.
317 //
318 // Ownership also returns through polling — an auto-reclaim once the local
319 // writer exits — rather than an explicit /reclaim. The surface is then still
320 // on the spectator-era projection and needs the barrier that re-hydrates it.
321 // Defer while a turn runs so the barrier never orphans an in-flight
322 // submission; a deferred barrier fires as soon as polling observes the surface
323 // idle.
324 func resolveRemoteTabOwnershipBarrierLocked(tab *remoteTab, before, after TabMeta, readyBarrier bool) (bool, bool) {
325 if before.TakenOver && !after.TakenOver {
326 if tab.runtime.running || tab.runtime.pendingPrompt {
327 tab.ownership.readyBarrierPending = true
328 } else {
329 readyBarrier = true
330 }
331 }
332 deferredBarrier := tab.ownership.readyBarrierPending && !tab.runtime.running && !tab.runtime.pendingPrompt
333 if deferredBarrier {
334 tab.ownership.readyBarrierPending = false
335 }
336 return readyBarrier, deferredBarrier
337 }
338
339 // remoteTabStatusMetaChanged reports whether a status refresh moved a field the
340 // tab strip renders, keeping remote-tab:updated off unchanged refreshes.
341 func remoteTabStatusMetaChanged(before, after TabMeta) bool {
342 return before.SessionPath != after.SessionPath || before.TopicID != after.TopicID ||
343 before.Running != after.Running || before.TurnStartedAt != after.TurnStartedAt ||
344 before.PendingPrompt != after.PendingPrompt || before.BackgroundJobs != after.BackgroundJobs ||
345 before.CancelRequested != after.CancelRequested || before.Cancellable != after.Cancellable ||
346 before.TakenOver != after.TakenOver
347 }
348
349 func applyRemoteTabStatusPayload(tab *remoteTab, payload remoteTabStatusPayload) {
350 payloadRoute := remoteSessionIdentityRoute(payload.SessionPath, payload.SessionID)
351 if payload.RuntimeState != nil && validRuntimeState(*payload.RuntimeState) {
352 acceptRemoteRuntimeStateLocked(tab, payloadRoute, *payload.RuntimeState, true)
353 payload.Running, payload.PendingPrompt, payload.BackgroundJobs, payload.CancelRequested, payload.Cancellable = nil, nil, nil, nil, nil
354 } else if payload.RuntimeState == nil && payload.Running != nil {
355 // An actual legacy status confirms only its selected session. Never
356 // leave a previous schema-1 observation shadowing these legacy facts.
357 delete(tab.runtimeStates, tab.routing.currentPath)
358 delete(tab.runtimeUnknown, tab.routing.currentPath)
359 tab.runtime.snapshot = event.RuntimeStateSnapshot{}
360 tab.runtime.syncFailed = false
361 }
362 if name := strings.TrimSpace(payload.SessionName); name != "" {
363 tab.session.name = name
364 tab.session.newSession = false
365 tab.session.reset = false
366 }
367 if payload.Running != nil {
368 tab.runtime.running = *payload.Running
369 if tab.routing.currentPath != "" {
370 if tab.routing.running == nil {
371 tab.routing.running = map[string]bool{}
372 }
373 tab.routing.revision++
374 tab.routing.running[tab.routing.currentPath] = *payload.Running
375 }
376 }
377 if payload.TakenOver != nil {
378 tab.session.takenOver = *payload.TakenOver
379 }
380 if payload.PendingPrompt != nil {
381 tab.runtime.pendingPrompt = *payload.PendingPrompt
382 }
383 if payload.BackgroundJobs != nil {
384 tab.runtime.backgroundJobs = max(0, *payload.BackgroundJobs)
385 }
386 if payload.CancelRequested != nil {
387 tab.runtime.cancelRequested = *payload.CancelRequested
388 }
389 if payload.Cancellable != nil {
390 tab.runtime.cancellable = *payload.Cancellable
391 }
392 if (tab.runtime.running || tab.runtime.pendingPrompt) && tab.runtime.turnStartedAt <= 0 {
393 tab.runtime.turnStartedAt = time.Now().UnixMilli()
394 } else if !tab.runtime.running && !tab.runtime.pendingPrompt {
395 tab.runtime.turnStartedAt = 0
396 }
397 }
398
399 func remoteTabReadyBarrier(tab *remoteTab, pathChanged bool) bool {
400 return pathChanged && tab != nil && tab.state == "ready"
401 }
402
403 func (a *App) recordRemoteTabModelCatalog(tabID string, client *http.Client, gen uint64, models json.RawMessage) {
404 if gen == 0 || a.remoteTabLocalProxy(tabID) {
405 return
406 }
407 var payload struct {
408 Current string `json:"current"`
409 Models []struct {
410 Ref string `json:"ref"`
411 Active bool `json:"active"`
412 } `json:"models"`
413 }
414 if json.Unmarshal(models, &payload) != nil {
415 return
416 }
417 current := strings.TrimSpace(payload.Current)
418 if current == "" {
419 for _, entry := range payload.Models {
420 if entry.Active {
421 current = strings.TrimSpace(entry.Ref)
422 break
423 }
424 }
425 }
426 if current == "" {
427 return
428 }
429 a.remoteTabMu.Lock()
430 tab := a.remoteTabs[tabID]
431 if tab == nil || tab.client != client || tab.gen != gen || tab.model == current {
432 a.remoteTabMu.Unlock()
433 return
434 }
435 tab.model = current
436 tab.modelSeq = remoteTabModelSeq.Add(1)
437 meta := remoteTabMetaLocked(tab)
438 a.remoteTabMu.Unlock()
439 a.emitRemoteEvent("remote-tab:updated", meta)
440 a.saveTabsFromRemote()
441 }
442
443 // listTabsWithRemote merges the remote strip entries into a local tab list.
444 // A highlighted remote tab deactivates every local entry so the strip shows
445 // exactly one active tab.
446 func (a *App) listTabsWithRemote(local []TabMeta) []TabMeta {
447 localIDs := make([]string, 0, len(local))
448 for _, meta := range local {
449 localIDs = append(localIDs, meta.ID)
450 }
451 remote, remoteActive, stripOrder := a.remoteTabMetas(localIDs)
452 if remoteActive != "" {
453 for i := range local {
454 local[i].Active = false
455 }
456 }
457 if len(remote) == 0 {
458 return enrichTabMetas(local)
459 }
460 all := append(enrichTabMetas(local), remote...)
461 byID := make(map[string]TabMeta, len(all))
462 for _, meta := range all {
463 byID[meta.ID] = meta
464 }
465 out := make([]TabMeta, 0, len(all))
466 for _, id := range stripOrder {
467 if meta, ok := byID[id]; ok {
468 out = append(out, meta)
469 }
470 }
471 return out
472 }
473
473 lines GO