| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/sessioncatalog" |
| 12 | ) |
| 13 | |
| 14 | // sessionHeadLineageState is the lineage view state for a schema-2 session: |
| 15 | // its versions are heads of one log, never separate files. |
| 16 | const sessionHeadLineageState = "heads" |
| 17 | |
| 18 | // sessionHeadQuietPeriod is how long a covered head must have been idle before |
| 19 | // cleanup retires it; the writer that forked it seconds ago may still be on it. |
| 20 | var sessionHeadQuietPeriod = time.Minute |
| 21 | |
| 22 | // tabMetaAfterHeadSwitch publishes a tab whose controller replaced the |
| 23 | // transcript under the same session path. The generation bump makes the |
| 24 | // frontend rehydrate instead of patching the surface it showed before. |
| 25 | func (a *App) tabMetaAfterHeadSwitch(tab *WorkspaceTab) TabMeta { |
| 26 | if tab == nil { |
| 27 | return TabMeta{} |
| 28 | } |
| 29 | a.mu.Lock() |
| 30 | tab.SessionGeneration++ |
| 31 | if tab.sink != nil { |
| 32 | tab.sink.setSessionGeneration(tab.SessionGeneration) |
| 33 | } |
| 34 | meta := a.tabMeta(tab, a.activeTabID == tab.ID) |
| 35 | a.mu.Unlock() |
| 36 | a.invalidatePromptHistoryCache() |
| 37 | if meta.SessionPath != "" { |
| 38 | a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(meta.SessionPath)) |
| 39 | } |
| 40 | return meta |
| 41 | } |
| 42 | |
| 43 | func (a *App) tabForSessionPath(path string) (*WorkspaceTab, control.SessionAPI) { |
| 44 | key := sessionRuntimeKey(path) |
| 45 | if key == "" { |
| 46 | return nil, nil |
| 47 | } |
| 48 | a.mu.RLock() |
| 49 | defer a.mu.RUnlock() |
| 50 | for _, tab := range a.runtimeTabsLocked() { |
| 51 | if sessionRuntimeKey(tab.currentSessionPath()) == key { |
| 52 | return tab, tab.Ctrl |
| 53 | } |
| 54 | } |
| 55 | return nil, nil |
| 56 | } |
| 57 | |
| 58 | func topicRecordForLineage(topic sessioncatalog.TopicRecord, selectedPath string) (sessioncatalog.SessionRecord, bool) { |
| 59 | want := selectedPath |
| 60 | if sessioncatalog.PathIdentityKey(want) == "" { |
| 61 | want = topic.RepresentativePath |
| 62 | } |
| 63 | for _, record := range topic.Sessions { |
| 64 | if sameRecoveryLineagePath(record.Path, want) { |
| 65 | return record, true |
| 66 | } |
| 67 | } |
| 68 | return sessioncatalog.SessionRecord{}, false |
| 69 | } |
| 70 | |
| 71 | // sessionHeadLineage answers the versions view from the log itself when the |
| 72 | // selected session is schema 2; a schema-1 session reports no heads. |
| 73 | func (a *App) sessionHeadLineage(topic sessioncatalog.TopicRecord, selectedPath string) (RecoveryLineageView, bool) { |
| 74 | record, ok := topicRecordForLineage(topic, selectedPath) |
| 75 | if !ok { |
| 76 | return RecoveryLineageView{}, false |
| 77 | } |
| 78 | heads, err := agent.ListSessionHeads(record.Path) |
| 79 | if err != nil || len(heads) == 0 { |
| 80 | return RecoveryLineageView{}, false |
| 81 | } |
| 82 | return a.headLineageView(record, heads), true |
| 83 | } |
| 84 | |
| 85 | func (a *App) headLineageView(record sessioncatalog.SessionRecord, heads []agent.SessionHead) RecoveryLineageView { |
| 86 | out := RecoveryLineageView{GroupID: agent.BranchID(record.Path), State: sessionHeadLineageState, Members: []RecoveryLineageMember{}} |
| 87 | _, overlays := a.catalogRuntimeOverlays() |
| 88 | overlay := overlays[sessionRuntimeKey(record.Path)] |
| 89 | title := record.CustomTitle |
| 90 | if meta, ok, err := agent.LoadBranchMeta(record.Path); err == nil && ok { |
| 91 | title = meta.CustomTitle |
| 92 | } |
| 93 | for _, head := range heads { |
| 94 | if head.Retired { |
| 95 | continue |
| 96 | } |
| 97 | note := head.Name |
| 98 | if head.ID == agent.SessionMainHead && note == "" { |
| 99 | note = title |
| 100 | } |
| 101 | out.Members = append(out.Members, RecoveryLineageMember{ |
| 102 | Path: record.Path, HeadID: head.ID, HeadKind: head.Kind, HeadName: head.Name, Selected: head.Selected, |
| 103 | VersionKind: "head", VersionState: string(agent.VersionActive), ParentVersionID: head.ParentHead, |
| 104 | Role: sessioncatalog.RecoveryRoleNormal, Canonical: head.Selected, Turns: head.Turns, |
| 105 | Open: head.Selected && overlay.open, Running: head.Selected && overlay.running, |
| 106 | VersionNote: note, Preview: head.Preview, |
| 107 | CreatedAt: unixMilliOrZero(head.CreatedAt), LastActivityAt: unixMilliOrZero(head.LastActivity), |
| 108 | }) |
| 109 | out.BranchCount++ |
| 110 | if head.Covered { |
| 111 | out.CleanupEligible++ |
| 112 | } |
| 113 | } |
| 114 | return out |
| 115 | } |
| 116 | |
| 117 | // mergeHeadLineage folds the heads of a log into the file lineage of the same |
| 118 | // conversation. The log's own file row gives way to its heads; the selected |
| 119 | // head is canonical only when that row was, or when no file lineage exists. |
| 120 | func mergeHeadLineage(file, heads RecoveryLineageView) RecoveryLineageView { |
| 121 | if len(heads.Members) == 0 { |
| 122 | return file |
| 123 | } |
| 124 | logPath := heads.Members[0].Path |
| 125 | out := heads |
| 126 | selectedIsCanonical := len(file.Members) == 0 |
| 127 | for _, member := range file.Members { |
| 128 | if sameRecoveryLineagePath(member.Path, logPath) { |
| 129 | selectedIsCanonical = member.Canonical |
| 130 | continue |
| 131 | } |
| 132 | out.Members = append(out.Members, member) |
| 133 | } |
| 134 | if !selectedIsCanonical { |
| 135 | for i := range out.Members[:len(heads.Members)] { |
| 136 | out.Members[i].Canonical = false |
| 137 | } |
| 138 | } |
| 139 | out.BranchCount = len(out.Members) |
| 140 | out.Unresolved = file.Unresolved |
| 141 | out.CleanupEligible += file.CleanupEligible |
| 142 | if len(file.Members) > 0 { |
| 143 | out.GroupID, out.State = file.GroupID, file.State |
| 144 | } |
| 145 | return out |
| 146 | } |
| 147 | |
| 148 | func unixMilliOrZero(at time.Time) int64 { |
| 149 | if at.IsZero() { |
| 150 | return 0 |
| 151 | } |
| 152 | return at.UnixMilli() |
| 153 | } |
| 154 | |
| 155 | // chooseSessionHead makes one head the conversation's current version. An |
| 156 | // open controller switches in place so its tab keeps identity and path; a |
| 157 | // closed session gets a select marker and lands on the head when reopened. |
| 158 | func (a *App) chooseSessionHead(req RecoveryPreferenceRequest) error { |
| 159 | path, headID := strings.TrimSpace(req.Path), strings.TrimSpace(req.HeadID) |
| 160 | if _, _, err := a.sessionDirForPath(path); err != nil { |
| 161 | return errors.New("session version is unavailable") |
| 162 | } |
| 163 | if tab, ctrl := a.tabForSessionPath(path); ctrl != nil { |
| 164 | if a.tabIsReadOnly(tab) { |
| 165 | return readOnlyChannelErr() |
| 166 | } |
| 167 | if _, err := ctrl.SwitchBranch(headID); err != nil { |
| 168 | return err |
| 169 | } |
| 170 | a.tabMetaAfterHeadSwitch(tab) |
| 171 | } else if err := agent.SelectSessionHead(path, headID); err != nil { |
| 172 | return friendlySessionFileError(err) |
| 173 | } |
| 174 | dir := filepath.Dir(path) |
| 175 | if catalog := a.sessionCatalog.Load(); catalog != nil { |
| 176 | target := sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot} |
| 177 | if err := catalog.ReconcileDirectory(a.bootContext(), target); err != nil { |
| 178 | return errors.New("the version choice was saved but the session catalog could not refresh") |
| 179 | } |
| 180 | } |
| 181 | a.emitProjectTreeChangedForSessionDirs(dir) |
| 182 | return nil |
| 183 | } |
| 184 | |
| 185 | // cleanTopicHeads routes cleanup to the heads of a schema-2 log when the |
| 186 | // topic's representative session is one; ok is false for file lineages. |
| 187 | func (a *App) cleanTopicHeads(req RecoveryCleanupRequest, topic sessioncatalog.TopicRecord) (RecoveryCleanupResult, bool) { |
| 188 | record, ok := topicRecordForLineage(topic, "") |
| 189 | if !ok { |
| 190 | return RecoveryCleanupResult{}, false |
| 191 | } |
| 192 | heads, err := agent.ListSessionHeads(record.Path) |
| 193 | if err != nil || len(heads) == 0 { |
| 194 | return RecoveryCleanupResult{}, false |
| 195 | } |
| 196 | return a.cleanSessionHeads(req, record.Path, heads), true |
| 197 | } |
| 198 | |
| 199 | // cleanSessionHeads retires covered heads: versions whose whole chain is |
| 200 | // already part of the current one. Diverged heads are never touched, and a |
| 201 | // head active within the quiet period is reported busy rather than retired. |
| 202 | func (a *App) cleanSessionHeads(req RecoveryCleanupRequest, path string, heads []agent.SessionHead) RecoveryCleanupResult { |
| 203 | result := RecoveryCleanupResult{DryRun: !req.Apply, Items: []RecoveryCleanupItem{}} |
| 204 | if req.Apply { |
| 205 | a.sessionRemovalMu.Lock() |
| 206 | defer a.sessionRemovalMu.Unlock() |
| 207 | } |
| 208 | for _, head := range heads { |
| 209 | if head.Retired || head.Selected || !head.Covered { |
| 210 | continue |
| 211 | } |
| 212 | result.Eligible++ |
| 213 | item := RecoveryCleanupItem{Path: path, HeadID: head.ID, Status: "eligible"} |
| 214 | if req.Apply { |
| 215 | item.Status, item.Error = retireCoveredHead(path, head, &result) |
| 216 | } |
| 217 | result.Items = append(result.Items, item) |
| 218 | } |
| 219 | if result.Moved > 0 { |
| 220 | a.emitProjectTreeChangedForSessionDirs(filepath.Dir(path)) |
| 221 | } |
| 222 | return result |
| 223 | } |
| 224 | |
| 225 | func retireCoveredHead(path string, head agent.SessionHead, result *RecoveryCleanupResult) (string, string) { |
| 226 | if time.Since(head.LastActivity) < sessionHeadQuietPeriod { |
| 227 | result.Busy++ |
| 228 | return "busy", "" |
| 229 | } |
| 230 | if err := agent.RetireSessionHead(path, head.ID); err != nil { |
| 231 | result.Kept++ |
| 232 | return "kept", "session version changed and was kept" |
| 233 | } |
| 234 | result.Moved++ |
| 235 | return "retired", "" |
| 236 | } |
| 237 | |
| 238 | // RenameSessionHead names one head of a schema-2 session. The main head has |
| 239 | // no name of its own: its note is the session title, so it renames the session. |
| 240 | func (a *App) RenameSessionHead(path, headID, name string) error { |
| 241 | headID = strings.TrimSpace(headID) |
| 242 | if headID == "" || headID == agent.SessionMainHead { |
| 243 | return a.RenameSession(path, name) |
| 244 | } |
| 245 | if _, _, err := a.sessionDirForPath(path); err != nil { |
| 246 | return errors.New("session version is unavailable") |
| 247 | } |
| 248 | if err := agent.RenameSessionHead(path, headID, strings.TrimSpace(name)); err != nil { |
| 249 | return friendlySessionFileError(err) |
| 250 | } |
| 251 | a.emitProjectTreeChangedForSessionDirs(filepath.Dir(path)) |
| 252 | return nil |
| 253 | } |
| 254 |