返回 DeepSeek-Reasonix
remote_listing.go
根目录 / desktop / remote_listing.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "maps"
10 "net"
11 "net/http"
12 "net/http/cookiejar"
13 "net/url"
14 "strings"
15 "time"
16
17 "reasonix/internal/servecontract"
18 )
19
20 const (
21 serveSnapshotMaxBytes = 32 << 20
22 serveSessionsMaxBytes = 8 << 20
23 serveEventMaxBytes = 8 << 20
24 )
25
26 // This listing-only bridge lets project groups show sessions before the full
27 // remote-tab attach and event-pump surface lands.
28
29 // serveSessionEntry mirrors one GET /sessions row from the Serve.
30 type serveSessionEntry struct {
31 HostID string `json:"hostId"`
32 SessionID string `json:"sessionId"`
33 Name string `json:"name"`
34 Path string `json:"path"`
35 Title string `json:"title"`
36 Turns int `json:"turns"`
37 Current bool `json:"current"`
38 Running bool `json:"running"`
39 TakenOver bool `json:"takenOver,omitempty"`
40 MtimeMilli int64 `json:"mtimeMilli"`
41
42 Preview string `json:"preview,omitempty"`
43 MetadataReady bool `json:"metadataReady,omitempty"`
44 }
45
46 type serveHTTPStatusError struct {
47 url string
48 statusCode int
49 message string
50 }
51
52 func (e *serveHTTPStatusError) Error() string {
53 if e.message != "" {
54 return fmt.Sprintf("%s: status %d: %s", e.url, e.statusCode, e.message)
55 }
56 return fmt.Sprintf("%s: status %d", e.url, e.statusCode)
57 }
58
59 // RemoteSessionView mirrors one serve /sessions entry on the frontend side.
60 type RemoteSessionView struct {
61 HostID string `json:"hostId,omitempty"`
62 SessionID string `json:"sessionId,omitempty"`
63 Name string `json:"name"`
64 Path string `json:"path,omitempty"`
65 Title string `json:"title,omitempty"`
66 Turns int `json:"turns,omitempty"`
67 Current bool `json:"current,omitempty"`
68 Running bool `json:"running,omitempty"`
69 LastActivityAt int64 `json:"lastActivityAt,omitempty"`
70 Pinned bool `json:"pinned,omitempty"`
71 }
72
73 // serveURL joins a serve base URL and an API path.
74 func serveURL(base, path string) string {
75 return strings.TrimRight(base, "/") + path
76 }
77
78 func newServeHTTPClient(base string) (*http.Client, error) {
79 parsed, err := url.Parse(strings.TrimSpace(base))
80 if err != nil {
81 return nil, fmt.Errorf("invalid remote serve URL: %w", err)
82 }
83 ip := net.ParseIP(parsed.Hostname())
84 if parsed.Scheme != "http" || ip == nil || !ip.IsLoopback() || parsed.User != nil {
85 return nil, fmt.Errorf("remote serve URL must use loopback HTTP")
86 }
87 jar, err := cookiejar.New(nil)
88 if err != nil {
89 return nil, err
90 }
91 transport := http.DefaultTransport.(*http.Transport).Clone()
92 transport.Proxy = nil
93 return &http.Client{
94 Jar: jar,
95 Transport: transport,
96 CheckRedirect: func(*http.Request, []*http.Request) error {
97 return http.ErrUseLastResponse
98 },
99 }, nil
100 }
101
102 // servePost keeps the bounded response text in failures so remote lease and
103 // busy-state hints reach the desktop surface.
104 func servePost(ctx context.Context, client *http.Client, url string, body []byte) error {
105 _, err := servePostSessionPath(ctx, client, url, body)
106 return err
107 }
108
109 const expectedSessionPathHeader = "X-Reasonix-Expected-Session-Path"
110 const expectedSessionIDHeader = "X-Reasonix-Expected-Session-ID"
111 const expectedModelSettingsHeader = "X-Reasonix-Expected-Model-Settings"
112 const remoteSessionIDRoutePrefix = "session-id:"
113
114 func remoteSessionIdentityRoute(path, sessionID string) string {
115 // A session the Serve migrated into the identity catalog keeps its legacy
116 // path only as a read-only artifact; the identity is the live route.
117 if sessionID = strings.TrimSpace(sessionID); sessionID != "" {
118 return remoteSessionIDRoutePrefix + sessionID
119 }
120 if path = strings.TrimSpace(path); path != "" {
121 return path
122 }
123 return ""
124 }
125
126 func remoteSessionRoute(entry serveSessionEntry) string {
127 return remoteSessionIdentityRoute(entry.Path, entry.SessionID)
128 }
129
130 // remoteSessionRouteIdentity inverts remoteSessionIdentityRoute: rows built
131 // from a live route must expose an identity route as SessionID, never as a
132 // path, or resuming the row sends Serve a filesystem path it cannot resolve.
133 func remoteSessionRouteIdentity(route string) (path, sessionID string) {
134 if id, ok := strings.CutPrefix(route, remoteSessionIDRoutePrefix); ok {
135 return "", id
136 }
137 return route, ""
138 }
139
140 // servePostForSession fences a foreground mutation to the session the Desktop
141 // tab displayed when the command was issued. Older Serve binaries ignore the
142 // optional header and retain their single-session behavior.
143 func servePostForSession(ctx context.Context, client *http.Client, url string, body []byte, expectedPath string, modelRevision ...string) error {
144 if body == nil {
145 body = []byte("{}")
146 }
147 resp, err := serveDoForSession(ctx, client, http.MethodPost, url, body, expectedPath, modelRevision...)
148 if err != nil {
149 return err
150 }
151 defer resp.Body.Close()
152 data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
153 if resp.StatusCode >= 200 && resp.StatusCode < 300 {
154 return nil
155 }
156 return &serveHTTPStatusError{
157 url: url, statusCode: resp.StatusCode, message: strings.TrimSpace(string(data)),
158 }
159 }
160
161 // servePostSessionPath preserves the ordinary 2xx contract while reading the
162 // optional path header returned by session-rotation endpoints. Older Serve
163 // binaries omit it and keep their legacy untagged single-session behavior.
164 func servePostSessionPath(ctx context.Context, client *http.Client, url string, body []byte) (string, error) {
165 identity, err := servePostSessionIdentityForSession(ctx, client, url, body, "")
166 return identity.Path, err
167 }
168
169 type serveSessionIdentity struct {
170 Path string
171 SessionID string
172 TakenOver bool
173 }
174
175 const sessionTakenOverHeader = "X-Reasonix-Taken-Over"
176
177 func servePostSessionIdentityForSession(ctx context.Context, client *http.Client, url string, body []byte, expectedPath string) (serveSessionIdentity, error) {
178 if body == nil {
179 body = []byte("{}")
180 }
181 if strings.HasSuffix(strings.TrimRight(url, "/"), "/resume") {
182 var request struct {
183 Path string `json:"path"`
184 SessionID string `json:"sessionId"`
185 }
186 if err := json.Unmarshal(body, &request); err != nil {
187 return serveSessionIdentity{}, fmt.Errorf("invalid remote resume request: %w", err)
188 }
189 if strings.TrimSpace(request.Path) == "" && strings.TrimSpace(request.SessionID) == "" {
190 return serveSessionIdentity{}, fmt.Errorf("remote resume requires a session path or sessionId")
191 }
192 }
193 resp, err := serveDoForSession(ctx, client, http.MethodPost, url, body, expectedPath)
194 if err != nil {
195 return serveSessionIdentity{}, err
196 }
197 defer resp.Body.Close()
198 data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
199 if resp.StatusCode >= 200 && resp.StatusCode < 300 {
200 return serveSessionIdentity{
201 Path: strings.TrimSpace(resp.Header.Get("X-Reasonix-Session-Path")),
202 SessionID: strings.TrimSpace(resp.Header.Get("X-Reasonix-Session-ID")),
203 // A 204 with the taken-over header means the serve mounted this
204 // caller as a read-only spectator: another runtime owns the writer.
205 TakenOver: strings.TrimSpace(resp.Header.Get(sessionTakenOverHeader)) != "",
206 }, nil
207 }
208 return serveSessionIdentity{}, &serveHTTPStatusError{
209 url: url, statusCode: resp.StatusCode, message: strings.TrimSpace(string(data)),
210 }
211 }
212
213 // serveDo issues a JSON request; the csrf guard rejects non-JSON POSTs.
214 func serveDo(ctx context.Context, client *http.Client, method, url string, body []byte) (*http.Response, error) {
215 return serveDoForSession(ctx, client, method, url, body, "")
216 }
217
218 func serveDoForSession(ctx context.Context, client *http.Client, method, url string, body []byte, expectedPath string, modelRevision ...string) (*http.Response, error) {
219 req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
220 if err != nil {
221 return nil, err
222 }
223 req.Header.Set("Content-Type", "application/json")
224 if len(modelRevision) > 0 && modelRevision[0] != "" {
225 req.Header.Set(expectedModelSettingsHeader, modelRevision[0])
226 }
227 if expectedPath = strings.TrimSpace(expectedPath); expectedPath != "" {
228 if sessionID, ok := strings.CutPrefix(expectedPath, remoteSessionIDRoutePrefix); ok {
229 req.Header.Set(expectedSessionIDHeader, sessionID)
230 } else {
231 req.Header.Set(expectedSessionPathHeader, expectedPath)
232 }
233 }
234 return client.Do(req)
235 }
236
237 // serveCapabilitiesHeader carries the comma-joined capability tokens a serve
238 // advertises on a successful token handshake (e.g. "browser").
239 const serveCapabilitiesHeader = "X-Reasonix-Serve-Capabilities"
240 const serveCapabilityExecutionV2 = "execution-v2"
241 const serveCapabilitySessions = "session-history-v1"
242 const serveCapabilitySessionContentV1 = "session-content-v1"
243 const serveCapabilitySessionReadV2 = "session-read-v2"
244
245 const serveCapabilityHistoryWindowV1 = "history-window-v1"
246 const serveCapabilityExtensionFormInstanceV1 = "extension-form-instance-v1"
247 const serveCapabilityInteractionTargetV1 = "interaction-target-v1"
248 const serveCapabilitySessionIdentityV1 = "session-identity-v1"
249 const serveCapabilitySessionOwnershipV1 = "session-ownership-v1"
250 const serveCapabilityGoalLifecycleV2 = servecontract.GoalLifecycleV2
251 const serveCapabilitySessionForkTargetsV1 = servecontract.SessionForkTargetsV1
252
253 // serveHandshakeCapabilities exchanges the pre-shared token for the session
254 // cookie and returns the serve's advertised capabilities; older serves omit
255 // the header and yield nil, which callers must read as "no capabilities".
256 func serveHandshakeCapabilities(ctx context.Context, client *http.Client, base, token string) ([]string, error) {
257 body, err := json.Marshal(map[string]string{"token": token})
258 if err != nil {
259 return nil, err
260 }
261 resp, err := serveDo(ctx, client, http.MethodPost, serveURL(base, "/auth/token"), body)
262 if err != nil {
263 return nil, err
264 }
265 defer resp.Body.Close()
266 _, _ = io.Copy(io.Discard, resp.Body)
267 if resp.StatusCode != http.StatusNoContent {
268 return nil, fmt.Errorf("serve auth handshake: status %d", resp.StatusCode)
269 }
270 var caps []string
271 for cap := range strings.SplitSeq(resp.Header.Get(serveCapabilitiesHeader), ",") {
272 if cap = strings.TrimSpace(cap); cap != "" {
273 caps = append(caps, cap)
274 }
275 }
276 return caps, nil
277 }
278
279 // serveHandshake exchanges the pre-shared token for the session cookie.
280 // Serve replies 204 on success; the cookie lands in client's jar.
281 func serveHandshake(ctx context.Context, client *http.Client, base, token string) error {
282 _, err := serveHandshakeCapabilities(ctx, client, base, token)
283 return err
284 }
285
286 // serveSessions lists the serve's sessions.
287 func serveSessions(ctx context.Context, client *http.Client, base string) ([]serveSessionEntry, error) {
288 req, err := http.NewRequestWithContext(ctx, http.MethodGet, serveURL(base, "/sessions"), nil)
289 if err != nil {
290 return nil, err
291 }
292 resp, err := client.Do(req)
293 if err != nil {
294 return nil, err
295 }
296 defer resp.Body.Close()
297 if resp.StatusCode != http.StatusOK {
298 return nil, fmt.Errorf("serve /sessions: status %d", resp.StatusCode)
299 }
300 data, err := io.ReadAll(io.LimitReader(resp.Body, serveSessionsMaxBytes+1))
301 if err != nil {
302 return nil, err
303 }
304 if len(data) > serveSessionsMaxBytes {
305 return nil, fmt.Errorf("serve /sessions response exceeds %d bytes", serveSessionsMaxBytes)
306 }
307 var out []serveSessionEntry
308 if err := json.Unmarshal(data, &out); err != nil {
309 return nil, err
310 }
311 return out, nil
312 }
313
314 func singleCurrentServeSession(entries []serveSessionEntry) *serveSessionEntry {
315 var current *serveSessionEntry
316 for i := range entries {
317 if !entries[i].Current {
318 continue
319 }
320 if current != nil {
321 return nil
322 }
323 current = &entries[i]
324 }
325 return current
326 }
327
328 // serveClientForRef resolves an HTTP client for a host+workspace WITHOUT
329 // waking anything: a one-shot handshake against an already-ready serve
330 // registration. A serve that is not running reports an error — query paths
331 // must never cold-start one.
332 func (a *App) serveClientForRef(hostID, workspace string) (*http.Client, string, func(), error) {
333 a.remoteTabMu.Lock()
334 for _, tab := range a.remoteTabs {
335 if tab.ref.HostID == hostID && tab.ref.Workspace == workspace && tab.state == "ready" && tab.client != nil {
336 client, base := tab.client, tab.base
337 a.remoteTabMu.Unlock()
338 return client, base, func() {}, nil
339 }
340 }
341 a.remoteTabMu.Unlock()
342
343 rt, err := a.remoteRT()
344 if err != nil {
345 return nil, "", nil, err
346 }
347 view, token, ok := rt.ServeSnapshot(hostID, workspace)
348 if !ok {
349 return nil, "", nil, fmt.Errorf("remote serve for %s:%s is not running", hostID, workspace)
350 }
351 ctx := a.bootContext()
352 if ctx == nil {
353 ctx = context.Background()
354 }
355 callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
356 client, clientErr := newServeHTTPClient(view.LocalURL)
357 if clientErr != nil {
358 cancel()
359 return nil, "", nil, clientErr
360 }
361 if err := serveHandshake(callCtx, client, view.LocalURL, token); err != nil {
362 cancel()
363 return nil, "", nil, err
364 }
365 return client, view.LocalURL, cancel, nil
366 }
367
368 // serveClientEnsured may connect the host and start Serve, so only explicit
369 // user-intent paths should use it. Passive listings remain read-only.
370 func (a *App) serveClientEnsured(hostID, workspace string) (*http.Client, string, func(), error) {
371 if client, base, done, err := a.serveClientForRef(hostID, workspace); err == nil {
372 return client, base, done, nil
373 }
374 rt, err := a.remoteRT()
375 if err != nil {
376 return nil, "", nil, err
377 }
378 if err := rt.Connect(hostID); err != nil {
379 return nil, "", nil, err
380 }
381 if err := waitForRemoteHost(rt, hostID, 60*time.Second); err != nil {
382 return nil, "", nil, err
383 }
384 bootCtx := a.bootContext()
385 if bootCtx == nil {
386 bootCtx = context.Background()
387 }
388 view, token, err := rt.EnsureServer(bootCtx, hostID, workspace)
389 if err != nil {
390 return nil, "", nil, err
391 }
392 callCtx, cancel := context.WithTimeout(bootCtx, 30*time.Second)
393 client, err := newServeHTTPClient(view.LocalURL)
394 if err != nil {
395 cancel()
396 return nil, "", nil, err
397 }
398 if err := serveHandshake(callCtx, client, view.LocalURL, token); err != nil {
399 cancel()
400 return nil, "", nil, err
401 }
402 return client, view.LocalURL, cancel, nil
403 }
404
405 // RemoteProjectSessions lists a remote project's serve sessions for the
406 // project tree. Live-tab fast paths, desktop title overrides and pinned
407 // synthesis arrive with the remote sessions PR.
408 func (a *App) RemoteProjectSessions(hostID, workspace string) ([]RemoteSessionView, error) {
409 client, base, done, err := a.serveClientForRef(hostID, workspace)
410 if err != nil {
411 return nil, err
412 }
413 defer done()
414 ctx, cancel := commandContext(a)
415 defer cancel()
416 return a.remoteProjectSessions(ctx, client, base, hostID, workspace)
417 }
418
419 // EnsureRemoteProjectSessions is the explicit group-open listing path. It can
420 // wake the SSH host and Serve before returning sessions.
421 func (a *App) EnsureRemoteProjectSessions(hostID, workspace string) ([]RemoteSessionView, error) {
422 client, base, done, err := a.serveClientEnsured(hostID, workspace)
423 if err != nil {
424 return nil, err
425 }
426 defer done()
427 ctx, cancel := commandContext(a)
428 defer cancel()
429 return a.remoteProjectSessions(ctx, client, base, hostID, workspace)
430 }
431
432 func (a *App) remoteProjectSessions(ctx context.Context, client *http.Client, base, hostID, workspace string) ([]RemoteSessionView, error) {
433 listing, err := a.fetchRemoteSessionListing(ctx, client, base, hostID, workspace)
434 if err != nil {
435 return nil, err
436 }
437 entries := listing.entries
438 liveRunning := listing.liveRunning
439 liveCurrentPath := listing.liveCurrentPath
440 preferLiveCurrent := listing.preferLive
441 out := make([]RemoteSessionView, 0, len(entries))
442 pinned := make([]RemoteSessionView, 0, len(entries))
443 prefs := remotePrefsSnapshot()
444 hasCurrent := false
445 for _, e := range entries {
446 title := strings.TrimSpace(e.Title)
447 prefKey := remoteSessionPrefKey(hostID, workspace, e.Name)
448 if override := prefs.SessionTitles[prefKey]; override != "" {
449 title = override
450 }
451 pinnedRow := remoteSessionPinnedLocked(prefs, prefKey)
452 // A never-chatted canonical session is the remote analog of a local
453 // blank, so hide it unless pinned. MetadataReady gates the check: a
454 // stale catalog must not hide a real conversation.
455 if e.SessionID != "" && !e.Current && !pinnedRow && e.Turns == 0 && title == "" && e.Preview == "" && e.MetadataReady {
456 continue
457 }
458 current := e.Current
459 route := remoteSessionRoute(e)
460 if preferLiveCurrent {
461 current = liveCurrentPath != "" && route == liveCurrentPath
462 }
463 view := RemoteSessionView{
464 HostID: e.HostID, SessionID: e.SessionID, Name: e.Name, Path: e.Path, Title: title, Turns: e.Turns, Current: current,
465 Running: remoteSessionRunning(e.Running, liveRunning, route, preferLiveCurrent),
466 LastActivityAt: e.MtimeMilli,
467 Pinned: pinnedRow,
468 }
469 hasCurrent = hasCurrent || view.Current
470 if view.Pinned {
471 pinned = append(pinned, view)
472 } else {
473 out = append(out, view)
474 }
475 }
476 if !hasCurrent {
477 // A fresh foreground session stays absent from /sessions until its first
478 // transcript save. Synthesize it from the live route rather than reset,
479 // which status clears as soon as Serve names the not-yet-listed session.
480 a.remoteTabMu.Lock()
481 listedRoutes := make(map[string]bool, len(entries))
482 for _, e := range entries {
483 listedRoutes[remoteSessionIdentityRoute(strings.TrimSpace(e.Path), strings.TrimSpace(e.SessionID))] = true
484 }
485 var blank *RemoteSessionView
486 for _, tab := range a.remoteTabs {
487 if tab.ref.HostID != hostID || tab.ref.Workspace != workspace {
488 continue
489 }
490 if tab.state != "ready" || blank != nil {
491 continue
492 }
493 // Known current path: blank while the serve listing cannot see it
494 // yet. Unknown path (a legacy /new without a path header): blank
495 // while the fresh-session marker is still set.
496 if route := tab.routing.currentPath; route != "" {
497 if !listedRoutes[route] {
498 path, sessionID := remoteSessionRouteIdentity(route)
499 blank = &RemoteSessionView{Name: "", Path: path, SessionID: sessionID, Title: tab.topicTitle, Current: true, Running: tab.runtime.running, LastActivityAt: time.Now().UnixMilli()}
500 }
501 } else if tab.session.reset {
502 blank = &RemoteSessionView{Name: "", Title: tab.topicTitle, Current: true, Running: tab.runtime.running, LastActivityAt: time.Now().UnixMilli()}
503 }
504 }
505 a.remoteTabMu.Unlock()
506 if blank != nil {
507 return append([]RemoteSessionView{*blank}, append(pinned, out...)...), nil
508 }
509 }
510 return append(pinned, out...), nil
511 }
512
513 type remoteSessionListing struct {
514 entries []serveSessionEntry
515 liveRunning map[string]bool
516 liveCurrentPath string
517 preferLive bool
518 }
519
520 func (a *App) fetchRemoteSessionListing(ctx context.Context, client *http.Client, base, hostID, workspace string) (remoteSessionListing, error) {
521 const maxRaceRetries = 2
522
523 listingAttempt:
524 for attempt := 0; ; attempt++ {
525 a.remoteTabMu.Lock()
526 var observedTab *remoteTab
527 var observedRevision uint64
528 for _, tab := range a.remoteTabs {
529 if tab.ref.HostID == hostID && tab.ref.Workspace == workspace {
530 observedTab, observedRevision = tab, tab.routing.revision
531 break
532 }
533 }
534 a.remoteTabMu.Unlock()
535 entries, err := serveSessions(ctx, client, base)
536 if err != nil {
537 return remoteSessionListing{}, err
538 }
539 authoritativeCurrent := singleCurrentServeSession(entries)
540 authoritativeTitle := remoteAuthoritativeSessionTitle(hostID, workspace, authoritativeCurrent)
541 unlockRoute := lockRemoteTabRoute(observedTab)
542 a.remoteTabMu.Lock()
543 liveRunning := map[string]bool{}
544 liveCurrentPath := ""
545 preferLiveCurrent := false
546 var routeUpdate *TabMeta
547 routeReadyBarrier := false
548 for _, tab := range a.remoteTabs {
549 if tab.ref.HostID != hostID || tab.ref.Workspace != workspace {
550 continue
551 }
552 // Without a newer SSE/status revision, /sessions replaces the running
553 // cache and current route. A raced revision preserves the newer live route
554 // instead of marking both its row and the stale server row current.
555 authoritativeListing := tab == observedTab && tab.routing.revision == observedRevision
556 if !authoritativeListing && attempt < maxRaceRetries && remoteSessionRunningConflict(entries, tab.routing.running) {
557 a.remoteTabMu.Unlock()
558 unlockRoute()
559 continue listingAttempt
560 }
561 if authoritativeListing {
562 authoritative := make(map[string]bool, len(entries))
563 for _, entry := range entries {
564 route := remoteSessionRoute(entry)
565 authoritative[route] = entry.Running
566 if route == tab.routing.currentPath {
567 tab.session.takenOver = entry.TakenOver
568 }
569 }
570 tab.routing.running = authoritative
571 if authoritativeCurrent != nil {
572 path := remoteSessionRoute(*authoritativeCurrent)
573 // The listing's "current" is Serve's foreground; it must not
574 // re-route a spectator's explicitly selected session.
575 if path == tab.routing.currentPath || !tab.session.takenOver {
576 pathChanged := adoptRemoteTabSessionPathLocked(tab, path)
577 tab.session.name = strings.TrimSpace(authoritativeCurrent.Name)
578 if pathChanged {
579 tab.topicTitle = authoritativeTitle
580 meta := remoteTabMetaLocked(tab)
581 routeUpdate = &meta
582 routeReadyBarrier = remoteTabReadyBarrier(tab, true)
583 }
584 }
585 }
586 } else {
587 preferLiveCurrent = true
588 }
589 liveCurrentPath = tab.routing.currentPath
590 maps.Copy(liveRunning, tab.routing.running)
591 break
592 }
593 a.remoteTabMu.Unlock()
594 if routeUpdate != nil {
595 a.emitRemoteEvent("remote-tab:updated", *routeUpdate)
596 if routeReadyBarrier {
597 a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", routeUpdate.ID), RemoteTabStateView{State: "ready"})
598 }
599 }
600 unlockRoute()
601 return remoteSessionListing{
602 entries: entries, liveRunning: liveRunning,
603 liveCurrentPath: liveCurrentPath, preferLive: preferLiveCurrent,
604 }, nil
605 }
606 }
607
608 func remoteSessionRunningConflict(entries []serveSessionEntry, live map[string]bool) bool {
609 listedPaths := make(map[string]struct{}, len(entries))
610 for _, entry := range entries {
611 route := remoteSessionRoute(entry)
612 listedPaths[route] = struct{}{}
613 if running, ok := live[route]; entry.Running && ok && !running {
614 return true
615 }
616 }
617 for path, running := range live {
618 if _, listed := listedPaths[path]; !running && !listed {
619 return true
620 }
621 }
622 return false
623 }
624
625 func remoteSessionRunning(listed bool, live map[string]bool, path string, preferLive bool) bool {
626 if preferLive {
627 if running, ok := live[path]; ok {
628 // A raced false can mean a completed turn or remaining background jobs.
629 // The bounded refresh resolves the ordinary case; after repeated races,
630 // retain the conservative row rather than hiding active background work.
631 if listed && !running {
632 return true
633 }
634 return running
635 }
636 }
637 return listed || live[path]
638 }
639
640 func remoteAuthoritativeSessionTitle(hostID, workspace string, current *serveSessionEntry) string {
641 if current == nil {
642 return ""
643 }
644 title := strings.TrimSpace(current.Title)
645 if override := remoteSessionTitleOverride(hostID, workspace, current.Name); override != "" {
646 title = override
647 }
648 if title == "" {
649 title = remoteWorkspaceName(workspace)
650 }
651 return title
652 }
653
653 lines GO