返回 DeepSeek-Reasonix
remote_projects.go
根目录 / desktop / remote_projects.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/event"
15 )
16
17 // remoteTabModelSeq stamps every remote-tab model assignment; the credential
18 // proxy uses the stamps to resolve "most recently set" without relying on map
19 // iteration order.
20 var remoteTabModelSeq atomic.Uint64
21
22 // ── View structs mirrored in frontend/src/lib/types.ts ──
23
24 // RemoteTabRef marks a tab as remote and binds it to a host+workspace pair.
25 type RemoteTabRef struct {
26 HostID string `json:"hostId"`
27 Workspace string `json:"workspace"`
28 }
29
30 type RemoteProjectView struct {
31 HostID string `json:"hostId"`
32 Workspace string `json:"workspace"`
33 Title string `json:"title,omitempty"`
34 Color string `json:"color,omitempty"`
35 // Merged marks that an overlapping pin already existed and the returned
36 // Workspace is that existing group's canonical path — no new pin was added.
37 Merged bool `json:"merged,omitempty"`
38 }
39
40 // RemoteTabOpenOptions mirrors the frontend opts bag: NewSession lands the
41 // tab in a fresh serve session; SessionName resumes a listed one.
42 type RemoteTabOpenOptions struct {
43 NewSession bool `json:"newSession,omitempty"`
44 SessionName string `json:"sessionName,omitempty"`
45 SessionPath string `json:"sessionPath,omitempty"`
46 SessionID string `json:"sessionId,omitempty"`
47 SessionTitle string `json:"sessionTitle,omitempty"`
48 }
49
50 // RemoteTabStateView is the payload on the remote-tab:{id}:state channel.
51 // State: connecting | ready | reconnecting | serve_down | error.
52 type RemoteTabStateView struct {
53 State string `json:"state"`
54 Error string `json:"error,omitempty"`
55 }
56
57 // remoteTab is one open remote project tab.
58 type remoteTab struct {
59 selectionMu sync.Mutex // orders resume outcomes and queued registrations
60 sessionMu sync.Mutex
61 // routeEventMu orders foreground-route adoption with route-scoped frames.
62 // It stays separate from remoteTabMu so frontend callbacks run unlocked;
63 // lock order is routeEventMu, then App.remoteTabMu.
64 routeEventMu sync.Mutex
65 id string
66 ref RemoteTabRef
67 state string
68 err string
69 session remoteTabSessionState
70 hostLabel string
71 // topicTitle starts as the workspace name and adopts the generated title.
72 topicTitle string
73 titleRefresh remoteTabTitleRefreshState
74 // model is the desktop-owned current model ref for this remote tab.
75 // modelSeq orders concurrent writes for deterministic proxy registration.
76 model string
77 modelSeq uint64
78 settings remoteModelApplicationState // guarded by remoteTabMu
79
80 // Bridge fields are protected by App.remoteTabMu. gen fences old pumps;
81 // client preserves cookies and token permits a new handshake.
82 client *http.Client
83 base string
84 token string
85 // capabilities are fixed by the authenticated serve handshake. Missing
86 // permission-presets-v1 means history-only compatibility with an old serve.
87 capabilities map[string]bool
88 gen uint64
89 cancel context.CancelFunc
90 // attachedGen marks a pump that survived the open/session-entry barrier.
91 // It stays internal so ListTabs never exposes a transient non-wire state.
92 attachedGen uint64
93 // Pending approval/ask frames are retained while the frontend surface is
94 // inactive. RemoteTabSnapshot replays them when that surface mounts again.
95 pendingEvents map[string]json.RawMessage
96 persistenceExtra map[string]json.RawMessage
97
98 // Transient runtime state is projected into TabMeta even while this tab is
99 // inactive, matching the local tab strip's running/prompt/job indicators.
100 runtime remoteTabRuntimeState
101 runtimeStates map[string]event.RuntimeStateSnapshot
102 runtimeUnknown map[string]uint64
103 runtimeConflicts map[string]event.RuntimeStateSnapshot
104 // routing fences all-session SSE and retains background project-tree state.
105 routing remoteTabSessionRouting
106 // selectionRevision fences async OpenRemoteProjectTab resumes so an older
107 // rejected request cannot restore metadata over a newer user selection.
108 selectionRevision uint64
109 pendingSelection *remoteTabPendingOpenSelection
110 // ownership fences the handback of this session's writer; see
111 // remoteTabOwnershipState in remote_tab_reclaim.go.
112 ownership remoteTabOwnershipState
113 }
114
115 type remoteTabRuntimeState struct {
116 syncFailed bool
117 snapshot event.RuntimeStateSnapshot
118 // revision orders asynchronous /status snapshots against newer requests
119 // and SSE-derived runtime mutations within the same connection generation.
120 revision uint64
121 running bool
122 turnStartedAt int64
123 pendingPrompt bool
124 backgroundJobs int
125 cancelRequested bool
126 cancellable bool
127 }
128
129 type remoteTabTitleRefreshState struct {
130 path string
131 seq uint64
132 }
133
134 type remoteTabSessionState struct {
135 newSession bool
136 name string
137 path string
138 sessionID string
139 reset bool
140 // takenOver marks a session a local runtime on the serve host took over:
141 // this tab is a read-only spectator until it reclaims the session.
142 takenOver bool
143 // instanceID identifies the Serve process that owns this session. A
144 // changed id requires explicit /new or /resume re-entry before ready.
145 instanceID string
146 }
147
148 type remoteTabLayoutState struct {
149 order []string
150 stripOrder []string
151 activeID string
152 }
153
154 // ── Remote project tabs ──
155
156 type remoteTabOpenRegistration struct {
157 reuseID string
158 reuseBlank bool
159 revive bool
160 commitSelection bool
161 selection *remoteTabPendingOpenSelection
162 previousSelection *remoteTabOpenSelection
163 retired []context.CancelFunc
164 }
165
166 type remoteTabOpenSelection struct {
167 session remoteTabSessionState
168 topicTitle string
169 currentPath string
170 pending map[string]json.RawMessage
171 runtime remoteTabRuntimeState
172 revision uint64
173 }
174
175 // registerRemoteTabOpen serializes reuse, error-shell retirement, and insert.
176 func (a *App) registerRemoteTabOpen(tab *remoteTab, hostLabel string, opts RemoteTabOpenOptions) remoteTabOpenRegistration {
177 a.remoteTabMu.Lock()
178 defer a.remoteTabMu.Unlock()
179 if a.remoteTabs == nil {
180 a.remoteTabs = map[string]*remoteTab{}
181 }
182 var result remoteTabOpenRegistration
183 for _, existing := range a.remoteTabs {
184 if existing.ref != tab.ref || existing.state == "error" {
185 continue
186 }
187 result.reuseID = existing.id
188 result.reuseBlank = existing.session.reset
189 result.revive = existing.state == "disconnected" || existing.state == "serve_down"
190 // A ready tab keeps its current route until /new succeeds. Revived shells
191 // still need the requested blank identity committed before bootstrap so
192 // their first attach enters the requested session.
193 result.commitSelection = strings.TrimSpace(opts.SessionName) != "" || strings.TrimSpace(opts.SessionPath) != "" || strings.TrimSpace(opts.SessionID) != "" || opts.NewSession && result.revive
194 if strings.TrimSpace(opts.SessionName) != "" || strings.TrimSpace(opts.SessionPath) != "" || strings.TrimSpace(opts.SessionID) != "" || opts.NewSession {
195 result.selection = newRemoteTabPendingOpenSelection(opts)
196 result.previousSelection = &remoteTabOpenSelection{
197 session: existing.session, topicTitle: existing.topicTitle,
198 currentPath: existing.routing.currentPath,
199 pending: cloneRemotePendingEvents(existing.pendingEvents), runtime: existing.runtime,
200 }
201 }
202 return result
203 }
204 for id, existing := range a.remoteTabs {
205 if existing.ref != tab.ref || existing.state != "error" {
206 continue
207 }
208 if existing.cancel != nil {
209 result.retired = append(result.retired, existing.cancel)
210 }
211 delete(a.remoteTabs, id)
212 a.remoteTabLayout.order = removeRemoteTabOrderID(a.remoteTabLayout.order, id)
213 a.forgetRemoteBrowserExecutor(id)
214 }
215 tab.modelSeq = remoteTabModelSeq.Add(1)
216 a.remoteTabs[tab.id] = tab
217 a.remoteTabLayout.order = append(a.remoteTabLayout.order, tab.id)
218 return result
219 }
220
221 // commitRemoteTabOpenRegistration applies a reused shell's requested identity
222 // only after the single-surface visibility transaction has succeeded. Until
223 // then the persisted shell and Serve remain aligned on the previous session.
224 func (a *App) commitRemoteTabOpenRegistration(registration *remoteTabOpenRegistration, hostLabel string, opts RemoteTabOpenOptions) bool {
225 if registration.reuseID == "" {
226 return false
227 }
228 a.remoteTabMu.Lock()
229 existing := a.remoteTabs[registration.reuseID]
230 if existing == nil {
231 a.remoteTabMu.Unlock()
232 return false
233 }
234 a.remoteTabMu.Unlock()
235 if !existing.selectionMu.TryLock() {
236 a.remoteTabMu.Lock()
237 defer a.remoteTabMu.Unlock()
238 if a.remoteTabs[registration.reuseID] != existing {
239 return false
240 }
241 existing.hostLabel = hostLabel
242 if registration.selection != nil {
243 registration.selection.deferred = true
244 registration.selection.revision = 0
245 registration.selection.identityCommitted = false
246 registration.selection.previous = nil
247 existing.pendingSelection = registration.selection
248 }
249 return true
250 }
251 defer existing.selectionMu.Unlock()
252 existing.routeEventMu.Lock()
253 defer existing.routeEventMu.Unlock()
254 a.remoteTabMu.Lock()
255 defer a.remoteTabMu.Unlock()
256 if a.remoteTabs[registration.reuseID] != existing {
257 return false
258 }
259 existing.hostLabel = hostLabel
260 if registration.selection != nil {
261 registration.reuseBlank = existing.session.reset
262 registration.previousSelection = &remoteTabOpenSelection{
263 session: existing.session, topicTitle: existing.topicTitle,
264 currentPath: existing.routing.currentPath,
265 pending: cloneRemotePendingEvents(existing.pendingEvents), runtime: existing.runtime,
266 }
267 // A ready selection commits its provisional identity before its async
268 // /resume starts. If another click wins first, restore the snapshot Serve
269 // still owns before committing the newer provisional identity.
270 pending := existing.pendingSelection
271 if pending != nil && pending.identityCommitted && pending.previous != nil {
272 restoreRemoteTabOpenSelectionLocked(existing, pending.previous)
273 registration.previousSelection = pending.previous
274 registration.reuseBlank = pending.previous.session.reset
275 }
276 existing.selectionRevision++
277 registration.selection.revision = existing.selectionRevision
278 registration.selection.reuseBlank = registration.reuseBlank
279 registration.selection.previous = registration.previousSelection
280 if registration.previousSelection != nil {
281 registration.previousSelection.revision = existing.selectionRevision
282 }
283 if existing.state == "connecting" || existing.state == "reconnecting" {
284 registration.selection.deferred = true
285 existing.pendingSelection = registration.selection
286 return true
287 }
288 existing.pendingSelection = nil
289 }
290 if registration.commitSelection {
291 existing.session.newSession = opts.NewSession
292 existing.session.name = strings.TrimSpace(opts.SessionName)
293 existing.session.path = strings.TrimSpace(opts.SessionPath)
294 existing.session.sessionID = strings.TrimSpace(opts.SessionID)
295 if title := strings.TrimSpace(opts.SessionTitle); title != "" {
296 existing.topicTitle = title
297 }
298 if existing.session.newSession {
299 commitRemoteTabAttachRoute(existing, "", true)
300 } else if route := remoteSessionIdentityRoute(existing.session.path, existing.session.sessionID); route != "" {
301 // A ready tab commits the route ahead of its async /resume; gate
302 // commands until that resume settles. Re-selecting the confirmed
303 // current session is not a switch and must not enter the gate.
304 switching := existing.state == "ready" && existing.routing.currentPath != route
305 commitRemoteTabAttachRoute(existing, route, false)
306 if switching {
307 existing.routing.rehydratingPath = route
308 }
309 }
310 if existing.state == "ready" {
311 registration.selection.identityCommitted = true
312 existing.pendingSelection = registration.selection
313 }
314 existing.err = ""
315 }
316 if registration.revive {
317 existing.state = "connecting"
318 existing.err = ""
319 }
320 return true
321 }
322
323 // OpenRemoteProjectTab registers the project (idempotent), opens an in-app
324 // tab for the remote workspace, and returns its meta immediately. The remote
325 // Serve bootstrap runs in the background: a first run downloads/installs the
326 // CLI and can take minutes, so the surface follows progress through
327 // remote-tab:{id}:state events instead of this promise.
328 func (a *App) OpenRemoteProjectTab(hostID, workspace string, opts RemoteTabOpenOptions) (TabMeta, error) {
329 a.singleSurfaceMu.Lock()
330 defer a.singleSurfaceMu.Unlock()
331 a.tabSelectionMu.Lock()
332 defer a.tabSelectionMu.Unlock()
333
334 hostID = strings.TrimSpace(hostID)
335 workspace = strings.TrimSpace(workspace)
336 if hostID == "" || workspace == "" {
337 return TabMeta{}, fmt.Errorf("remote project tab: host and workspace are required")
338 }
339 cfg, err := config.Load()
340 if err != nil {
341 return TabMeta{}, err
342 }
343 host, ok := cfg.RemoteHost(hostID)
344 if !ok {
345 return TabMeta{}, fmt.Errorf("remote host %q is not configured", hostID)
346 }
347 if err := a.snapshotActiveLocalBeforeRemote(); err != nil {
348 return TabMeta{}, err
349 }
350 // The pin registry collapses overlapping paths into the existing group:
351 // whatever nested path the caller asked for, the tab must land on the
352 // canonical workspace so tabs and serves stay one-per-group.
353 proj, err := a.AddRemoteProject(hostID, workspace)
354 if err != nil {
355 return TabMeta{}, err
356 }
357 workspace = proj.Workspace
358
359 ref := RemoteTabRef{HostID: hostID, Workspace: workspace}
360 tabID := newTabID()
361 model := ""
362 if host.CredentialProxyEnabled() {
363 model = resolveNewSessionModel(cfg)
364 }
365 title := strings.TrimSpace(opts.SessionTitle)
366 if title == "" {
367 title = remoteWorkspaceName(workspace)
368 }
369 tab := &remoteTab{
370 id: tabID, ref: ref, state: "connecting",
371 session: remoteTabSessionState{newSession: opts.NewSession, name: strings.TrimSpace(opts.SessionName), path: strings.TrimSpace(opts.SessionPath), sessionID: strings.TrimSpace(opts.SessionID)},
372 hostLabel: host.Name, topicTitle: title, model: model,
373 routing: remoteTabSessionRouting{currentPath: remoteSessionIdentityRoute(opts.SessionPath, opts.SessionID), running: map[string]bool{}},
374 }
375
376 // Reuse-or-insert is atomic so concurrent opens cannot create two sessions.
377 registration := a.registerRemoteTabOpen(tab, host.Name, opts)
378 for _, cancel := range registration.retired {
379 cancel()
380 }
381 if registration.reuseID != "" {
382 _, ok := a.remoteTabMetaSnapshot(registration.reuseID)
383 if !ok {
384 return TabMeta{}, fmt.Errorf("remote tab %q closed while opening", registration.reuseID)
385 }
386 _, err = a.keepOnlyRemoteVisibleTab(registration.reuseID)
387 if err != nil {
388 return TabMeta{}, err
389 }
390 if !a.commitRemoteTabOpenRegistration(&registration, host.Name, opts) {
391 return TabMeta{}, fmt.Errorf("remote tab %q closed while opening", registration.reuseID)
392 }
393
394 // Apply the requested session transition only after the visible-surface
395 // transaction succeeds. A snapshot failure must leave the remote Serve's
396 // current conversation untouched so retrying the navigation is safe.
397 if registration.revive {
398 a.emitRemoteTabState(registration.reuseID, "connecting", "")
399 a.goRemoteTabSafe("remoteTabServe", func() { a.bootstrapRemoteTab(registration.reuseID, hostID, workspace) })
400 } else if registration.selection != nil && registration.selection.deferred {
401 // The reconnect/attach path applies the latest selection after ready.
402 } else if name := strings.TrimSpace(opts.SessionName); name != "" || strings.TrimSpace(opts.SessionPath) != "" || strings.TrimSpace(opts.SessionID) != "" {
403 a.resumeRemoteTabOpenAsync(registration.reuseID, name, opts.SessionPath, opts.SessionTitle, registration.previousSelection)
404 } else {
405 // Reuse the pending blank like EnsureBlankTab does locally; only
406 // reset again once the current session earned content.
407 if opts.NewSession && !registration.reuseBlank {
408 if err := a.resetRemoteTabSession(registration.reuseID); err != nil {
409 return TabMeta{}, err
410 }
411 }
412 }
413 meta, ok := a.remoteTabMetaSnapshot(registration.reuseID)
414 if !ok {
415 return TabMeta{}, fmt.Errorf("remote tab %q closed while opening", registration.reuseID)
416 }
417 a.activateRemoteTab(registration.reuseID, meta)
418 a.saveTabsFromRemote()
419 return meta, nil
420 }
421
422 a.emitRemoteTabState(tabID, "connecting", "")
423 _, ok = a.remoteTabMetaSnapshot(tabID)
424 if !ok {
425 return TabMeta{}, fmt.Errorf("remote tab %q closed while opening", tabID)
426 }
427 meta, err := a.keepOnlyRemoteVisibleTab(tabID)
428 if err != nil {
429 _ = a.closeRemoteTabRegistration(tabID, true)
430 return TabMeta{}, err
431 }
432 a.activateRemoteTab(tabID, meta)
433 a.goRemoteTabSafe("remoteTabServe", func() { a.bootstrapRemoteTab(tabID, hostID, workspace) })
434 // Persist after activation so the file records the highlighted remote id.
435 a.saveTabsFromRemote()
436 return meta, nil
437 }
438
439 // restoreRemoteTabShells rebuilds disconnected registry entries from the
440 // persisted tab file so remote tabs survive a restart. Shells never connect
441 // on their own: activating one (SetActiveTab) or opening its project
442 // bootstraps the reconnect, which lands in a fresh blank session.
443 func (a *App) restoreRemoteTabShells(f desktopTabsFile) {
444 if len(f.RemoteTabs) == 0 {
445 return
446 }
447 // Local ids are snapshotted under a.mu BEFORE taking remoteTabMu — the
448 // save path locks in the a.mu → tabsSaveMu → remoteTabMu order, so this
449 // function must never hold remoteTabMu while wanting a.mu.
450 a.mu.RLock()
451 localIDs := make(map[string]bool, len(a.tabs))
452 for id := range a.tabs {
453 localIDs[id] = true
454 }
455 a.mu.RUnlock()
456
457 cfg, cfgErr := config.Load()
458 a.remoteTabMu.Lock()
459 if a.remoteTabs == nil {
460 a.remoteTabs = map[string]*remoteTab{}
461 }
462 a.remoteTabLayout.stripOrder = append([]string(nil), f.TabOrder...)
463 restoredIDs := make(map[string]bool, len(f.RemoteTabs))
464 for _, entry := range f.RemoteTabs {
465 id := strings.TrimSpace(entry.ID)
466 hostID := strings.TrimSpace(entry.HostID)
467 ws := strings.TrimSpace(entry.Workspace)
468 if id == "" || hostID == "" || ws == "" || localIDs[id] || a.remoteTabs[id] != nil {
469 continue
470 }
471 hostLabel, model := hostID, ""
472 if cfgErr == nil {
473 if host, ok := cfg.RemoteHost(hostID); ok {
474 if name := strings.TrimSpace(host.Name); name != "" {
475 hostLabel = name
476 }
477 if host.CredentialProxyEnabled() {
478 model = strings.TrimSpace(entry.Model)
479 }
480 }
481 }
482 sessionName := strings.TrimSpace(entry.SessionName)
483 sessionPath := strings.TrimSpace(entry.SessionPath)
484 sessionID := strings.TrimSpace(entry.SessionID)
485 title := strings.TrimSpace(entry.TopicTitle)
486 // Older builds persisted the canonical session ID as the tab title, and
487 // a legacy row's name is its basename, which the sidebar shows as the
488 // title too. Both are opaque, so drop them once an ID is present.
489 if sessionID != "" && (title == sessionID || title == sessionName) {
490 title = ""
491 }
492 if title == "" {
493 title = remoteWorkspaceName(ws)
494 }
495 route := remoteSessionIdentityRoute(sessionPath, sessionID)
496 restored := &remoteTab{
497 id: id, ref: RemoteTabRef{HostID: hostID, Workspace: ws},
498 state: "disconnected",
499 session: remoteTabSessionState{
500 newSession: entry.SessionReset || sessionName == "" && sessionPath == "",
501 name: sessionName,
502 path: sessionPath,
503 sessionID: sessionID,
504 reset: entry.SessionReset,
505 },
506 hostLabel: hostLabel, topicTitle: title, model: model, persistenceExtra: cloneDesktopJSONFields(entry.extra),
507 routing: remoteTabSessionRouting{currentPath: route, running: map[string]bool{}},
508 }
509 restored.modelSeq = remoteTabModelSeq.Add(1)
510 a.remoteTabs[id] = restored
511 restoredIDs[id] = true
512 }
513 seen := make(map[string]bool, len(restoredIDs))
514 for _, id := range f.RemoteTabOrder {
515 if restoredIDs[id] && !seen[id] {
516 a.remoteTabLayout.order = append(a.remoteTabLayout.order, id)
517 seen[id] = true
518 }
519 }
520 for _, entry := range f.RemoteTabs {
521 id := strings.TrimSpace(entry.ID)
522 if restoredIDs[id] && !seen[id] {
523 a.remoteTabLayout.order = append(a.remoteTabLayout.order, id)
524 seen[id] = true
525 }
526 }
527 a.remoteTabMu.Unlock()
528 }
529
530 // removeRemoteTabOrderID drops one id from the remote strip order.
531 func removeRemoteTabOrderID(order []string, id string) []string {
532 out := order[:0]
533 for _, existing := range order {
534 if existing != id {
535 out = append(out, existing)
536 }
537 }
538 return out
539 }
540
541 // activateRemoteTab highlights the tab in the strip and tells the frontend
542 // chrome to adopt it.
543 func (a *App) activateRemoteTab(tabID string, meta TabMeta) {
544 a.remoteTabMu.Lock()
545 a.remoteTabLayout.activeID = tabID
546 a.remoteTabMu.Unlock()
547 a.emitRemoteEvent("remote-tab:opened", meta)
548 }
549
550 // snapshotActiveLocalBeforeRemote preserves the same data-loss barrier used
551 // by local-to-local tab switches. The caller serializes cross-registry tab
552 // selection with tabSelectionMu.
553 func (a *App) snapshotActiveLocalBeforeRemote() error {
554 a.remoteTabMu.Lock()
555 remoteActive := a.remoteTabLayout.activeID != ""
556 a.remoteTabMu.Unlock()
557 if remoteActive {
558 return nil
559 }
560 a.mu.RLock()
561 active := a.tabs[a.activeTabID]
562 a.mu.RUnlock()
563 return a.snapshotTabForAction(active, "switching tabs")
564 }
565
566 // remoteTabMeta builds the frontend-facing shape of one remote tab; the
567 // create and reuse paths share it so both return identical metas. RemoteState
568 // seeds the surface before any state event arrives this run (restored shells).
569 func remoteTabMetaLocked(tab *remoteTab) TabMeta {
570 label := tab.hostLabel
571 if strings.TrimSpace(tab.model) != "" {
572 label = tab.model
573 }
574 ref := tab.ref
575 return TabMeta{
576 ID: tab.id,
577 Scope: "project",
578 WorkspaceRoot: tab.ref.Workspace,
579 WorkspaceName: remoteWorkspaceName(tab.ref.Workspace),
580 TopicID: remoteTabTopicID(tab),
581 TopicTitle: tab.topicTitle,
582 SessionPath: tab.session.path,
583 SessionID: tab.session.sessionID,
584 SessionGeneration: tab.gen,
585 Label: label,
586 Mode: "normal",
587 Active: true,
588 Cwd: tab.ref.Workspace,
589 Remote: &ref,
590 RemoteState: tab.state,
591 // Every remote tab's view is built here, so the capability answer cannot
592 // be forgotten on one path. Local tabs never reach this helper and keep
593 // the false zero value.
594 ForkTargetsSupported: remoteForkTargetsSupported(tab),
595 InteractionTargetSupported: tab.capabilities[serveCapabilityInteractionTargetV1],
596 ExtensionFormInstanceSupported: tab.capabilities[serveCapabilityExtensionFormInstanceV1],
597 Ready: tab.state == "ready",
598 Running: tab.runtime.running || tab.runtime.pendingPrompt || tab.runtime.backgroundJobs > 0,
599 TurnStartedAt: tab.runtime.turnStartedAt,
600 PendingPrompt: tab.runtime.pendingPrompt,
601 BackgroundJobs: tab.runtime.backgroundJobs,
602 CancelRequested: tab.runtime.cancelRequested,
603 Cancellable: tab.runtime.cancellable,
604 ReadOnly: tab.session.takenOver,
605 TakenOver: tab.session.takenOver,
606 }
607 }
608
609 func remoteTabTopicID(tab *remoteTab) string {
610 if tab == nil {
611 return ""
612 }
613 identity := tab.session.name
614 if sessionID := strings.TrimSpace(tab.session.sessionID); sessionID != "" {
615 identity = sessionID
616 }
617 return tab.ref.HostID + "\x00" + tab.ref.Workspace + "\x00" + identity
618 }
619
620 func (a *App) remoteTabMetaSnapshot(tabID string) (TabMeta, bool) {
621 a.remoteTabMu.Lock()
622 defer a.remoteTabMu.Unlock()
623 tab := a.remoteTabs[tabID]
624 if tab == nil {
625 return TabMeta{}, false
626 }
627 return remoteTabMetaLocked(tab), true
628 }
629
630 // bootstrapRemoteTab drives one remote tab to a terminal state: ensure the
631 // SSH connection, ensure the remote Serve + loopback tunnel, then report
632 // ready (or the failure) on the tab's state channel.
633 func (a *App) bootstrapRemoteTab(tabID, hostID, workspace string) {
634 // Idempotence guard: a concurrent reattach may have brought this tab to
635 // ready while the open call was still in flight — bootstrapping again
636 // would re-enter the session and stack a second pump.
637 a.remoteTabMu.Lock()
638 tabState := ""
639 if tab := a.remoteTabs[tabID]; tab != nil {
640 tabState = tab.state
641 }
642 a.remoteTabMu.Unlock()
643 if tabState == "ready" {
644 return
645 }
646 rt, err := a.remoteRT()
647 if err != nil {
648 a.emitRemoteTabState(tabID, "error", err.Error())
649 return
650 }
651 // Connect is idempotent: an already connecting/connected host returns
652 // nil, a stopped generation is replaced with a fresh dial.
653 if err := rt.Connect(hostID); err != nil {
654 a.emitRemoteTabState(tabID, "error", err.Error())
655 return
656 }
657 if err := waitForRemoteHost(rt, hostID, 60*time.Second); err != nil {
658 a.emitRemoteTabState(tabID, "error", err.Error())
659 return
660 }
661 ctx := a.bootContext()
662 if ctx == nil {
663 ctx = context.Background()
664 }
665 view, token, err := rt.EnsureServer(ctx, hostID, workspace)
666 if err != nil {
667 a.emitRemoteTabState(tabID, "error", err.Error())
668 return
669 }
670 if view.State != "ready" || view.LocalURL == "" {
671 msg := view.Error
672 if msg == "" {
673 msg = view.Message
674 }
675 if msg == "" {
676 msg = "remote serve did not report a local URL"
677 }
678 a.emitRemoteTabState(tabID, "serve_down", msg)
679 return
680 }
681 a.remoteTabMu.Lock()
682 openTab := a.remoteTabs[tabID]
683 if openTab == nil {
684 a.remoteTabMu.Unlock()
685 return // closed while the bootstrap was in flight
686 }
687 opts := RemoteTabOpenOptions{NewSession: openTab.session.newSession, SessionName: openTab.session.name, SessionPath: openTab.session.path, SessionID: openTab.session.sessionID, SessionTitle: openTab.topicTitle}
688 a.remoteTabMu.Unlock()
689 // ctx outlives the call: the pump derives from it, while the handshake
690 // and session entry inside run under a bounded sub-context.
691 entered, err := a.attachRemoteTabServe(ctx, tabID, view.LocalURL, token, view.InstanceID, opts)
692 if err != nil {
693 // Pump failures publish their own reconnecting/error state. Only a
694 // pre-attach failure should transition the original connecting shell.
695 a.remoteTabMu.Lock()
696 current := a.remoteTabs[tabID]
697 connecting := current != nil && current.state == "connecting"
698 a.remoteTabMu.Unlock()
699 if connecting {
700 a.emitRemoteTabState(tabID, "error", err.Error())
701 }
702 return
703 }
704 a.remoteTabMu.Lock()
705 openTab = a.remoteTabs[tabID]
706 if openTab == nil {
707 a.remoteTabMu.Unlock()
708 return
709 }
710 gen := openTab.gen
711 if openTab.attachedGen != gen || openTab.state != "connecting" {
712 a.remoteTabMu.Unlock()
713 return
714 }
715 openTab.session.reset = entered && opts.NewSession
716 freshSession := openTab.session.reset
717 if freshSession {
718 // A bootstrapped fresh session carries the localized default title,
719 // same as the live-tab reset path.
720 openTab.topicTitle = a.localizedDefaultTopicTitle()
721 }
722 a.remoteTabMu.Unlock()
723 if !a.publishRemoteTabAttachedReady(tabID, gen) {
724 return
725 }
726 if freshSession {
727 // A fresh session has no transcript in /sessions until its first save.
728 // The ready-only event does not refresh that listing, so publish the
729 // current ready meta in route/status order to prevent stale overwrites.
730 openTab.routeEventMu.Lock()
731 a.remoteTabMu.Lock()
732 current := a.remoteTabs[tabID]
733 if current != openTab || current.gen != gen || current.state != "ready" {
734 a.remoteTabMu.Unlock()
735 openTab.routeEventMu.Unlock()
736 return
737 }
738 meta := remoteTabMetaLocked(current)
739 a.remoteTabMu.Unlock()
740 a.emitRemoteEvent("remote-tab:updated", meta)
741 openTab.routeEventMu.Unlock()
742 }
743 // The confirmed /events pump makes the session usable without losing prompts.
744 // Saving the explorer default is auxiliary and cannot downgrade a healthy tab.
745 _ = a.saveLastRemoteWorkspace(hostID, workspace)
746 }
747
748 // waitForRemoteHost polls the kernel until the host is usable. The frontend
749 // has waitForRemoteConnection over status events; this is the same contract
750 // server-side for cold OpenRemoteProjectTab calls.
751 func waitForRemoteHost(rt remoteKernel, hostID string, timeout time.Duration) error {
752 deadline := time.Now().Add(timeout)
753 for {
754 for _, s := range rt.Statuses() {
755 if s.HostID != hostID {
756 continue
757 }
758 switch s.State {
759 case "connected", "degraded":
760 return nil
761 case "stopped":
762 if s.Error != "" {
763 return fmt.Errorf("remote host %q: %s", hostID, s.Error)
764 }
765 return fmt.Errorf("remote host %q stopped", hostID)
766 }
767 }
768 if time.Now().After(deadline) {
769 return fmt.Errorf("remote host %q: connection timed out", hostID)
770 }
771 time.Sleep(250 * time.Millisecond)
772 }
773 }
774
775 // remoteWorkspaceName is posix-safe (remote paths on a Windows host must not
776 // go through filepath).
777 func remoteWorkspaceName(ws string) string {
778 trimmed := strings.TrimRight(strings.TrimSpace(ws), "/")
779 if trimmed == "" || trimmed == "~" {
780 return "~"
781 }
782 if i := strings.LastIndex(trimmed, "/"); i >= 0 {
783 return trimmed[i+1:]
784 }
785 return trimmed
786 }
787
787 lines GO