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