| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "maps" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/session" |
| 14 | ) |
| 15 | |
| 16 | type projectTreeRuntimeState struct { |
| 17 | // activityAt records the last activity-status event per tab ID. The TTL |
| 18 | // watchdog reaps a live status that sees no terminating event; the state |
| 19 | // lives here (not on WorkspaceTab) to keep tabs.go within budget. |
| 20 | activityMu sync.Mutex |
| 21 | activityAt map[string]time.Time |
| 22 | } |
| 23 | |
| 24 | // noteActivityStatus refreshes a tab's activity timestamp on every status |
| 25 | // event, even an unchanged one: the TTL watchdog measures silence, not how |
| 26 | // long a status has been displayed, so a long but active turn is never reaped. |
| 27 | func (s *projectTreeRuntimeState) noteActivityStatus(a *App, tabID string) { |
| 28 | s.setActivityAt(tabID, time.Now()) |
| 29 | // Runtime snapshots, not silence, determine whether work remains active. |
| 30 | } |
| 31 | |
| 32 | func (s *projectTreeRuntimeState) setActivityAt(tabID string, at time.Time) { |
| 33 | s.activityMu.Lock() |
| 34 | defer s.activityMu.Unlock() |
| 35 | if s.activityAt == nil { |
| 36 | s.activityAt = map[string]time.Time{} |
| 37 | } |
| 38 | s.activityAt[tabID] = at |
| 39 | } |
| 40 | |
| 41 | func (s *projectTreeRuntimeState) activityAtSnapshot() map[string]time.Time { |
| 42 | s.activityMu.Lock() |
| 43 | defer s.activityMu.Unlock() |
| 44 | out := make(map[string]time.Time, len(s.activityAt)) |
| 45 | maps.Copy(out, s.activityAt) |
| 46 | return out |
| 47 | } |
| 48 | |
| 49 | func syncRuntimeWorkspaceRootSpelling(tab *WorkspaceTab, projects []desktopProject) bool { |
| 50 | if tab == nil || tab.Scope != "project" { |
| 51 | return false |
| 52 | } |
| 53 | i := projectIndexByRoot(projects, tab.WorkspaceRoot) |
| 54 | if i < 0 || tab.WorkspaceRoot == projects[i].Root { |
| 55 | return false |
| 56 | } |
| 57 | tab.WorkspaceRoot = projects[i].Root |
| 58 | return true |
| 59 | } |
| 60 | |
| 61 | // catalogRuntimeSnapshots copies runtime identity under App.mu, then lets all |
| 62 | // controller calls happen after the app lock is released. Controllers own |
| 63 | // their own locks and must never become part of the App.mu lock order. |
| 64 | func (a *App) catalogRuntimeSnapshots() []catalogRuntimeSnapshot { |
| 65 | if a == nil { |
| 66 | return []catalogRuntimeSnapshot{} |
| 67 | } |
| 68 | a.mu.RLock() |
| 69 | snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions)) |
| 70 | collect := func(tab *WorkspaceTab, open bool) { |
| 71 | if tab == nil || strings.TrimSpace(tab.TopicID) == "" { |
| 72 | return |
| 73 | } |
| 74 | snapshots = append(snapshots, catalogRuntimeSnapshot{ |
| 75 | tabID: tab.ID, |
| 76 | scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, |
| 77 | sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle, |
| 78 | topicTitleSource: tab.topicTitleSource, ctrl: tab.Ctrl, open: open, |
| 79 | }) |
| 80 | } |
| 81 | for _, tab := range a.tabs { |
| 82 | collect(tab, true) |
| 83 | } |
| 84 | for _, tab := range a.detachedSessions { |
| 85 | collect(tab, false) |
| 86 | } |
| 87 | a.mu.RUnlock() |
| 88 | return snapshots |
| 89 | } |
| 90 | |
| 91 | // GetProjectTreeRuntimeSnapshot returns the complete in-memory runtime |
| 92 | // projection. The frontend subscribes first and then calls this method; the |
| 93 | // independent revision makes either arrival order deterministic. |
| 94 | func (a *App) GetProjectTreeRuntimeSnapshot() ProjectTreeRuntimeSnapshot { |
| 95 | if a == nil { |
| 96 | return ProjectTreeRuntimeSnapshot{Topics: []ProjectRuntimeTopic{}} |
| 97 | } |
| 98 | snapshot := a.GetRuntimeStateSnapshot() |
| 99 | return ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics} |
| 100 | } |
| 101 | |
| 102 | func cloneRuntimeTopics(topics []ProjectRuntimeTopic) []ProjectRuntimeTopic { |
| 103 | var cloneNode func(ProjectNode) ProjectNode |
| 104 | cloneNode = func(node ProjectNode) ProjectNode { |
| 105 | next := node |
| 106 | next.Children = make([]ProjectNode, len(node.Children)) |
| 107 | for i, child := range node.Children { |
| 108 | next.Children[i] = cloneNode(child) |
| 109 | } |
| 110 | if node.Remote != nil { |
| 111 | remote := *node.Remote |
| 112 | next.Remote = &remote |
| 113 | } |
| 114 | return next |
| 115 | } |
| 116 | result := make([]ProjectRuntimeTopic, len(topics)) |
| 117 | for i, topic := range topics { |
| 118 | result[i] = topic |
| 119 | result[i].Node = cloneNode(topic.Node) |
| 120 | } |
| 121 | return result |
| 122 | } |
| 123 | |
| 124 | func (a *App) projectTreeRuntimeTopics(snapshots []catalogRuntimeSnapshot) []ProjectRuntimeTopic { |
| 125 | bySession := map[string]ProjectRuntimeTopic{} |
| 126 | state, _ := a.workspaceRegistry().Load(a.bootContext()) |
| 127 | for _, snapshot := range snapshots { |
| 128 | scope, root := normalizeDesktopTopicScope(snapshot.scope, snapshot.workspaceRoot) |
| 129 | snapshot.scope, snapshot.workspaceRoot = scope, root |
| 130 | if snapshot.sessionPath == "" && snapshot.ctrl != nil { |
| 131 | snapshot.sessionPath = snapshot.ctrl.SessionPath() |
| 132 | } |
| 133 | nodes, _ := a.runtimeProjectTopicNodes(scope, root, []catalogRuntimeSnapshot{snapshot}, false) |
| 134 | if len(nodes) == 0 { |
| 135 | continue |
| 136 | } |
| 137 | node := nodes[0] |
| 138 | node.TabID = snapshot.tabID |
| 139 | path := strings.TrimSpace(snapshot.sessionPath) |
| 140 | if id, ok := parseSessionRoute(path); ok { |
| 141 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id} |
| 142 | node.Session, node.SessionPath, node.Key = &ref, sessionRoute(id), "canonical_"+id |
| 143 | } else if identity, ok := snapshot.ctrl.(control.IdentityLifecycle); ok { |
| 144 | if ref, bound := identity.SessionRef(); bound && ref.SessionID != "" { |
| 145 | node.Session, node.SessionPath, node.Key = &ref, sessionRoute(ref.SessionID), "canonical_"+ref.SessionID |
| 146 | } |
| 147 | } |
| 148 | if node.Session == nil && path != "" { |
| 149 | node.SessionPath, node.Key = path, projectSessionNodeKey(scope, path) |
| 150 | } |
| 151 | if node.Session != nil { |
| 152 | node.IdentityAliases = sourceAliases(state, desktopWorkspaceOwnerID(state, scope, root), node.Session.SessionID) |
| 153 | node.LifecycleGeneration = state.SessionStates[node.Session.SessionID].Generation |
| 154 | if snapshot.tabID != "" { |
| 155 | node.IdentityAliases = append(node.IdentityAliases, "tab\x00local\x00"+snapshot.tabID) |
| 156 | } |
| 157 | } else if path == "" && snapshot.tabID != "" { |
| 158 | node.Key = "tab_" + snapshot.tabID |
| 159 | } |
| 160 | key := scope + "\x00" + root + "\x00" + node.Key |
| 161 | bySession[key] = ProjectRuntimeTopic{Scope: scope, WorkspaceRoot: root, Node: node} |
| 162 | } |
| 163 | keys := make([]string, 0, len(bySession)) |
| 164 | for key := range bySession { |
| 165 | keys = append(keys, key) |
| 166 | } |
| 167 | sort.Strings(keys) |
| 168 | topics := make([]ProjectRuntimeTopic, 0, len(keys)) |
| 169 | for _, key := range keys { |
| 170 | topics = append(topics, bySession[key]) |
| 171 | } |
| 172 | return topics |
| 173 | } |
| 174 | |
| 175 | func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, appCtx context.Context) bool { |
| 176 | attached := a.attachExistingSessionRuntimeCore(tab, path, appCtx) |
| 177 | if attached { |
| 178 | a.emitProjectTreeRuntimeChangedWithLegacy() |
| 179 | } |
| 180 | return attached |
| 181 | } |
| 182 | |
| 183 | func (a *App) closeTab(tabID string, allowDetach bool) error { |
| 184 | err := a.closeTabRuntime(tabID, allowDetach) |
| 185 | if err == nil { |
| 186 | a.emitProjectTreeRuntimeChangedWithLegacy() |
| 187 | } |
| 188 | return err |
| 189 | } |
| 190 | |
| 191 | func (a *App) emitProjectTreeChangedEvent() { |
| 192 | if a.projectTreeChangedHook != nil { |
| 193 | a.projectTreeChangedHook() |
| 194 | return |
| 195 | } |
| 196 | a.emitProjectTreeRuntimeChanged() |
| 197 | a.emitRuntimeEvent("project-tree:changed") |
| 198 | } |
| 199 | |
| 200 | func (a *App) emitProjectTreeRuntimeChanged() { |
| 201 | if a == nil { |
| 202 | return |
| 203 | } |
| 204 | snapshot := a.GetRuntimeStateSnapshot() |
| 205 | a.emitRuntimeEvent("project-tree:runtime-changed", ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics}) |
| 206 | a.emitRuntimeEvent("runtime-state:changed", snapshot) |
| 207 | } |
| 208 | |
| 209 | // The tagged legacy event keeps the previous frontend usable for one release. |
| 210 | func (a *App) emitProjectTreeRuntimeChangedWithLegacy() { |
| 211 | a.emitProjectTreeRuntimeChanged() |
| 212 | a.emitRuntimeEvent("project-tree:changed", map[string]string{"reason": "runtime"}) |
| 213 | } |
| 214 | |
| 215 | func (a *App) emitRuntimeEvent(name string, payload ...any) { |
| 216 | if a != nil && a.ctx != nil { |
| 217 | a.runtimeEvents.Emit(a.ctx, name, payload...) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | const ( |
| 222 | // topicActivityStatusTTL bounds how long a live spinner status may go |
| 223 | // without any turn event before it is treated as orphaned. A flat TTL is |
| 224 | // used instead of a per-session P99: turn durations are not tracked |
| 225 | // per session in this package, and simplicity wins (#8528/#8555/#8859). |
| 226 | topicActivityStatusTTL = 10 * time.Minute |
| 227 | ) |
| 228 | |
| 229 | // liveTopicActivityStatus reports statuses that must be terminated by a |
| 230 | // TurnDone event; if that event is lost the spinner would run forever. |
| 231 | // waiting_confirmation is excluded: it waits on the user, not on turn events. |
| 232 | func liveTopicActivityStatus(status string) bool { |
| 233 | switch status { |
| 234 | case topicStatusThinking, topicStatusStreaming: |
| 235 | return true |
| 236 | } |
| 237 | return false |
| 238 | } |
| 239 | |
| 240 | func (a *App) reapStaleTopicActivityStatus(now time.Time) { |
| 241 | activityAt := a.projectTreeRuntime.activityAtSnapshot() |
| 242 | a.mu.Lock() |
| 243 | var reaped []string |
| 244 | for _, tab := range a.runtimeTabsLocked() { |
| 245 | if tab == nil || !liveTopicActivityStatus(tab.ActivityStatus) { |
| 246 | continue |
| 247 | } |
| 248 | if at := activityAt[tab.ID]; at.IsZero() || now.Sub(at) < topicActivityStatusTTL { |
| 249 | continue |
| 250 | } |
| 251 | reaped = append(reaped, tab.ID+":"+tab.ActivityStatus) |
| 252 | tab.ActivityStatus = "" |
| 253 | } |
| 254 | a.mu.Unlock() |
| 255 | if len(reaped) == 0 { |
| 256 | return |
| 257 | } |
| 258 | slog.Warn("desktop: cleared stale topic activity status (no turn event within TTL)", |
| 259 | "tabs", reaped, "ttl", topicActivityStatusTTL.String()) |
| 260 | a.emitProjectTreeRuntimeChangedWithLegacy() |
| 261 | } |
| 262 | |
| 263 | // reconcileTabActivityStatus clears a live spinner status the session's |
| 264 | // controller does not corroborate — a TurnDone missed while the session was |
| 265 | // detached would otherwise spin forever after reopen. The controller query |
| 266 | // takes the controller's own lock, so it runs outside App.mu (the same lock |
| 267 | // order rule as catalogRuntimeSnapshots). |
| 268 | func (a *App) reconcileTabActivityStatus(tab *WorkspaceTab) bool { |
| 269 | if a == nil || tab == nil { |
| 270 | return false |
| 271 | } |
| 272 | a.mu.RLock() |
| 273 | status := tab.ActivityStatus |
| 274 | ctrl := tab.Ctrl |
| 275 | a.mu.RUnlock() |
| 276 | if ctrl == nil || !liveTopicActivityStatus(status) || ctrl.Running() { |
| 277 | return false |
| 278 | } |
| 279 | a.mu.Lock() |
| 280 | defer a.mu.Unlock() |
| 281 | if tab.ActivityStatus != status { |
| 282 | // A new turn (or its TurnDone) landed while the locks were dropped. |
| 283 | return false |
| 284 | } |
| 285 | tab.ActivityStatus = "" |
| 286 | slog.Warn("desktop: cleared stale topic activity status on session open", "tab", tab.ID, "status", status) |
| 287 | return true |
| 288 | } |
| 289 | |
| 290 | // setTabActivityStatus records the project-tree status for a tab's in-flight |
| 291 | // turn and notes the event time for the TTL watchdog. |
| 292 | func (a *App) setTabActivityStatus(tabID, status string) bool { |
| 293 | a.mu.Lock() |
| 294 | defer a.mu.Unlock() |
| 295 | tab := a.tabByEventSinkIDLocked(tabID) |
| 296 | if tab == nil { |
| 297 | return false |
| 298 | } |
| 299 | a.projectTreeRuntime.noteActivityStatus(a, tab.ID) |
| 300 | status = normalizeTopicStatus(status) |
| 301 | if tab.ActivityStatus == status { |
| 302 | return false |
| 303 | } |
| 304 | tab.ActivityStatus = status |
| 305 | return true |
| 306 | } |
| 307 |