| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | // canonicalResumeScanCap bounds how many final-format catalog rows one resume |
| 16 | // surface offers after ranking by recency. It matches the Serve-side listing |
| 17 | // page so both surfaces see the same conversation universe even on long-lived |
| 18 | // workspaces. |
| 19 | const canonicalResumeScanCap = 100 |
| 20 | |
| 21 | // canonicalResumeWalkCap bounds how many catalog rows one listing walks before |
| 22 | // ranking. The catalog pages in session-id order, not recency, so the newest |
| 23 | // conversation can sit on the last page; stopping after the first page hid it |
| 24 | // from the picker and from --continue. The cap keeps a pathological store from |
| 25 | // turning every /resume into an unbounded directory scan; rows beyond it are |
| 26 | // the lexically largest ids, not the newest activity. |
| 27 | const canonicalResumeWalkCap = 20 * canonicalResumeScanCap |
| 28 | |
| 29 | // cliResumeTarget is one resumable conversation: either a legacy transcript |
| 30 | // path or a final-format session identity. Exactly one side is set. |
| 31 | type cliResumeTarget struct { |
| 32 | path string // legacy .jsonl transcript |
| 33 | ref session.SessionRef // sessions-v4 identity (SessionID != "" when canonical) |
| 34 | } |
| 35 | |
| 36 | func (t cliResumeTarget) canonical() bool { return t.ref.SessionID != "" } |
| 37 | |
| 38 | func (t cliResumeTarget) empty() bool { return t.path == "" && t.ref.SessionID == "" } |
| 39 | |
| 40 | // canonicalCatalogLister is the catalog paging surface canonicalResumeEntries |
| 41 | // walks; *session.Query implements it. Tests page a synthetic catalog through |
| 42 | // the same code without building hundreds of on-disk sessions. |
| 43 | type canonicalCatalogLister interface { |
| 44 | List(ctx context.Context, cursor string, limit int) (session.SessionPage, error) |
| 45 | } |
| 46 | |
| 47 | // canonicalResumeEntries lists final-format (sessions-v4) sessions sharing the |
| 48 | // workspace of the legacy session dir. The catalog is the authoritative store |
| 49 | // once a legacy transcript has been imported, so every resume surface must |
| 50 | // offer these rows or switching between the desktop and the CLI hides history. |
| 51 | func canonicalResumeEntries(ctx context.Context, sessionDir string) []resumeEntry { |
| 52 | service := cliSessionService(sessionDir) |
| 53 | if service == nil { |
| 54 | return nil |
| 55 | } |
| 56 | return canonicalResumeEntriesFrom(ctx, service.Query()) |
| 57 | } |
| 58 | |
| 59 | // canonicalResumeEntriesFrom walks the whole catalog (up to |
| 60 | // canonicalResumeWalkCap rows) before sorting by recency and applying the |
| 61 | // display cap, so the newest conversation is offered regardless of where its |
| 62 | // id sorts. |
| 63 | func canonicalResumeEntriesFrom(ctx context.Context, catalog canonicalCatalogLister) []resumeEntry { |
| 64 | var out []resumeEntry |
| 65 | cursor := "" |
| 66 | for walked := 0; walked < canonicalResumeWalkCap; { |
| 67 | page, err := catalog.List(ctx, cursor, canonicalResumeScanCap) |
| 68 | if err != nil { |
| 69 | break |
| 70 | } |
| 71 | walked += len(page.Sessions) |
| 72 | for _, info := range page.Sessions { |
| 73 | if canonicalResumeHidden(info) { |
| 74 | continue |
| 75 | } |
| 76 | out = append(out, resumeEntry{ |
| 77 | session: canonicalResumeDisplayInfo(info), |
| 78 | target: cliResumeTarget{ref: info.Ref}, |
| 79 | }) |
| 80 | } |
| 81 | if page.NextCursor == "" || page.NextCursor == cursor || len(page.Sessions) == 0 { |
| 82 | break |
| 83 | } |
| 84 | cursor = page.NextCursor |
| 85 | } |
| 86 | sort.SliceStable(out, func(i, j int) bool { return out[i].session.ModTime.After(out[j].session.ModTime) }) |
| 87 | if len(out) > canonicalResumeScanCap { |
| 88 | out = out[:canonicalResumeScanCap] |
| 89 | } |
| 90 | return out |
| 91 | } |
| 92 | |
| 93 | // canonicalResumeHidden mirrors the legacy picker's empty-session rule for |
| 94 | // catalog rows: a session that never saw a user message (no completed turn, |
| 95 | // no preview, no title) is an empty placeholder and stays out of the picker. |
| 96 | // Rows whose metadata has not been rebuilt yet stay listed — an unindexed |
| 97 | // conversation must remain reachable, matching the desktop tree. |
| 98 | func canonicalResumeHidden(info session.SessionInfo) bool { |
| 99 | if info.MetadataStatus != session.MetadataReady { |
| 100 | return false |
| 101 | } |
| 102 | return info.Turns == 0 && strings.TrimSpace(info.Preview) == "" && strings.TrimSpace(info.Title) == "" |
| 103 | } |
| 104 | |
| 105 | // canonicalResumeDisplayInfo projects a catalog row onto the picker's legacy |
| 106 | // row shape: the v4 directory stands in for the transcript path, the event-log |
| 107 | // revision time drives recency ordering, and a model-set catalog title rides |
| 108 | // the custom-title slot so sessionPickerLabel prefers it over the preview. |
| 109 | func canonicalResumeDisplayInfo(info session.SessionInfo) agent.SessionInfo { |
| 110 | return agent.SessionInfo{ |
| 111 | Path: info.Path, Preview: strings.TrimSpace(info.Preview), |
| 112 | CustomTitle: strings.TrimSpace(info.Title), Turns: info.Turns, |
| 113 | ModTime: info.UpdatedAt, CountsKnown: true, |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // readMigrationMapping loads the workspace's legacy-to-final migration map. |
| 118 | // It is a plain file read: no session service is opened for the root, so |
| 119 | // listing a foreign project from the picker never creates a writer registry |
| 120 | // or schedules metadata rebuilds there. |
| 121 | func readMigrationMapping(sessionDir string) (string, session.MigrationMapping, bool) { |
| 122 | root := session.RootForLegacyDir(sessionDir) |
| 123 | if root == "" { |
| 124 | return "", session.MigrationMapping{}, false |
| 125 | } |
| 126 | data, err := os.ReadFile(filepath.Join(root, "migration-map.json")) |
| 127 | if err != nil { |
| 128 | return root, session.MigrationMapping{}, false |
| 129 | } |
| 130 | var mapping session.MigrationMapping |
| 131 | if json.Unmarshal(data, &mapping) != nil || mapping.SchemaVersion != session.SchemaVersion { |
| 132 | return root, session.MigrationMapping{}, false |
| 133 | } |
| 134 | return root, mapping, true |
| 135 | } |
| 136 | |
| 137 | // migratedLegacyIndex returns the legacy transcript paths that already have |
| 138 | // one final-format successor among the listed canonical entries, plus the |
| 139 | // reverse source-for-target map. Sources with exactly one successor are hidden |
| 140 | // from the picker: the canonical row is the continuation, and offering the |
| 141 | // frozen source again would fork a duplicate identity instead of resuming the |
| 142 | // conversation. Multiple successors stay visible for the same reason the |
| 143 | // Serve keeps them. |
| 144 | func migratedLegacyIndex(sessionDir string, canonical []resumeEntry) (map[string]struct{}, map[string]string) { |
| 145 | _, mapping, ok := readMigrationMapping(sessionDir) |
| 146 | if !ok { |
| 147 | return nil, nil |
| 148 | } |
| 149 | listed := make(map[string]struct{}, len(canonical)) |
| 150 | for _, entry := range canonical { |
| 151 | if entry.target.canonical() { |
| 152 | listed[entry.target.ref.SessionID] = struct{}{} |
| 153 | } |
| 154 | } |
| 155 | return migratedLegacyIndexWith(mapping, func(targetID string) bool { |
| 156 | _, ok := listed[targetID] |
| 157 | return ok |
| 158 | }) |
| 159 | } |
| 160 | |
| 161 | // migratedLegacyIndexWith folds the mapping into the hidden-source and |
| 162 | // source-for-target indexes, counting only successors the live predicate |
| 163 | // accepts: the current workspace requires a visible catalog row, a foreign |
| 164 | // workspace an existing session directory. |
| 165 | func migratedLegacyIndexWith(mapping session.MigrationMapping, live func(targetID string) bool) (map[string]struct{}, map[string]string) { |
| 166 | targets := make(map[string][]string) |
| 167 | for _, entry := range mapping.Entries { |
| 168 | source := agent.CanonicalSessionPath(entry.SourcePath) |
| 169 | target := strings.TrimSpace(entry.TargetID) |
| 170 | if source == "" || target == "" || !live(target) { |
| 171 | continue |
| 172 | } |
| 173 | seen := false |
| 174 | for _, existing := range targets[source] { |
| 175 | seen = seen || existing == target |
| 176 | } |
| 177 | if !seen { |
| 178 | targets[source] = append(targets[source], target) |
| 179 | } |
| 180 | } |
| 181 | bySource := make(map[string]struct{}, len(targets)) |
| 182 | byTarget := make(map[string]string, len(targets)) |
| 183 | for source, ids := range targets { |
| 184 | if len(ids) == 1 { |
| 185 | bySource[source] = struct{}{} |
| 186 | byTarget[ids[0]] = source |
| 187 | } |
| 188 | } |
| 189 | return bySource, byTarget |
| 190 | } |
| 191 | |
| 192 | // workspaceResumeScan captures the shared facts every resume surface needs |
| 193 | // from one workspace: the legacy rows with migrated sources hidden (keeping |
| 194 | // their branch ids for mirror folding and a by-path lookup for label |
| 195 | // borrowing), and the visible canonical rows with engine mirrors folded and |
| 196 | // migration borrows applied. |
| 197 | type workspaceResumeScan struct { |
| 198 | legacy []agent.SessionInfo |
| 199 | legacyByPath map[string]agent.SessionInfo |
| 200 | legacyIDs map[string]struct{} |
| 201 | canonical []resumeEntry |
| 202 | } |
| 203 | |
| 204 | func scanWorkspaceResume(ctx context.Context, sessionDir string) workspaceResumeScan { |
| 205 | canonical := canonicalResumeEntries(ctx, sessionDir) |
| 206 | bySource, byTarget := migratedLegacyIndex(sessionDir, canonical) |
| 207 | sessions, err := agent.ListSessions(sessionDir) |
| 208 | if err != nil { |
| 209 | sessions = nil |
| 210 | } |
| 211 | scan := workspaceResumeScan{ |
| 212 | legacyByPath: make(map[string]agent.SessionInfo, len(sessions)), |
| 213 | legacyIDs: make(map[string]struct{}, len(sessions)), |
| 214 | legacy: make([]agent.SessionInfo, 0, len(sessions)), |
| 215 | } |
| 216 | for _, info := range sessions { |
| 217 | scan.legacyByPath[agent.CanonicalSessionPath(info.Path)] = info |
| 218 | scan.legacyIDs[agent.BranchID(info.Path)] = struct{}{} |
| 219 | if _, hidden := bySource[agent.CanonicalSessionPath(info.Path)]; hidden { |
| 220 | continue |
| 221 | } |
| 222 | scan.legacy = append(scan.legacy, info) |
| 223 | } |
| 224 | for _, entry := range canonical { |
| 225 | // The engine mirrors an in-flight legacy transcript into a final-format |
| 226 | // event log whose session id is the legacy branch id. That mirror is |
| 227 | // plumbing, not a second conversation: fold it into the legacy row. |
| 228 | if _, mirrored := scan.legacyIDs[entry.target.ref.SessionID]; mirrored { |
| 229 | continue |
| 230 | } |
| 231 | // Mirror the Serve listing's migration borrow: until the catalog row |
| 232 | // grows its own title, the frozen source's label identifies the same |
| 233 | // conversation on both surfaces. |
| 234 | if source, migrated := byTarget[entry.target.ref.SessionID]; migrated { |
| 235 | if legacy, ok := scan.legacyByPath[source]; ok { |
| 236 | if entry.session.CustomTitle == "" { |
| 237 | entry.session.CustomTitle = firstNonEmpty(legacy.CustomTitle, legacy.TopicTitle, legacy.Preview) |
| 238 | } |
| 239 | if entry.session.Turns == 0 { |
| 240 | entry.session.Turns = legacy.Turns |
| 241 | } |
| 242 | if legacy.ModTime.After(entry.session.ModTime) { |
| 243 | entry.session.ModTime = legacy.ModTime |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | scan.canonical = append(scan.canonical, entry) |
| 248 | } |
| 249 | // Borrowed legacy activity can move a row, so the newest-first order the |
| 250 | // merge and --continue rely on is established after the borrows. |
| 251 | sort.SliceStable(scan.canonical, func(i, j int) bool { |
| 252 | return scan.canonical[i].session.ModTime.After(scan.canonical[j].session.ModTime) |
| 253 | }) |
| 254 | return scan |
| 255 | } |
| 256 | |
| 257 | // foreignProjectResumeRows returns another workspace's legacy transcript rows |
| 258 | // with migrated sources removed, in ListSessions' newest-first order. Only the |
| 259 | // migration map is consulted: a successor counts when its session directory |
| 260 | // exists, which stands in for the current workspace's "visible catalog row" |
| 261 | // rule without opening the foreign root's session service from this TUI. A |
| 262 | // canonical identity cannot be opened from this controller anyway, so the |
| 263 | // foreign rows stay legacy-only. |
| 264 | func foreignProjectResumeRows(sessionDir string) []agent.SessionInfo { |
| 265 | if sessionDir == "" { |
| 266 | return nil |
| 267 | } |
| 268 | sessions, err := agent.ListSessions(sessionDir) |
| 269 | if err != nil || len(sessions) == 0 { |
| 270 | return nil |
| 271 | } |
| 272 | var hidden map[string]struct{} |
| 273 | if root, mapping, ok := readMigrationMapping(sessionDir); ok { |
| 274 | hidden, _ = migratedLegacyIndexWith(mapping, func(targetID string) bool { |
| 275 | if !filepath.IsLocal(targetID) || filepath.Base(targetID) != targetID { |
| 276 | return false |
| 277 | } |
| 278 | info, statErr := os.Stat(filepath.Join(root, targetID)) |
| 279 | return statErr == nil && info.IsDir() |
| 280 | }) |
| 281 | } |
| 282 | rows := make([]agent.SessionInfo, 0, len(sessions)) |
| 283 | for _, info := range sessions { |
| 284 | if _, migrated := hidden[agent.CanonicalSessionPath(info.Path)]; migrated { |
| 285 | continue |
| 286 | } |
| 287 | rows = append(rows, info) |
| 288 | } |
| 289 | return rows |
| 290 | } |
| 291 | |
| 292 | // mergedResumeEntries unifies the legacy picker rows with the final-format |
| 293 | // catalog for one workspace, capped at limit with recovery families kept |
| 294 | // together and their leaf-first arrangement intact. Both hosts of the same |
| 295 | // workspace (desktop tree via Serve, CLI pickers) must offer the same |
| 296 | // conversations after a migration. |
| 297 | func mergedResumeEntries(sessionDir string, limit int) []resumeEntry { |
| 298 | if sessionDir == "" { |
| 299 | return nil |
| 300 | } |
| 301 | scan := scanWorkspaceResume(context.Background(), sessionDir) |
| 302 | return mergeResumeStores(orderResumeSessions(scan.legacy), scan.canonical, limit) |
| 303 | } |
| 304 | |
| 305 | // mergeResumeStores interleaves canonical rows with the ordered legacy rows |
| 306 | // without disturbing the legacy order itself: orderResumeSessions deliberately |
| 307 | // places a recovery family's writable leaf first, so numeric /resume indices |
| 308 | // must stay stable. Canonical rows slot between legacy family runs by |
| 309 | // recency, and the display cap keeps whole runs together. |
| 310 | func mergeResumeStores(legacy []agent.SessionInfo, canonical []resumeEntry, limit int) []resumeEntry { |
| 311 | byID := make(map[string]agent.SessionInfo, len(legacy)) |
| 312 | for _, session := range legacy { |
| 313 | byID[agent.BranchID(session.Path)] = session |
| 314 | } |
| 315 | merged := make([]resumeEntry, 0, len(legacy)+len(canonical)) |
| 316 | appendRun := func(run []agent.SessionInfo) { |
| 317 | for _, info := range run { |
| 318 | merged = append(merged, resumeEntry{session: info, target: cliResumeTarget{path: info.Path}}) |
| 319 | } |
| 320 | } |
| 321 | nextCanonical := 0 |
| 322 | runStart := 0 |
| 323 | for runStart < len(legacy) { |
| 324 | key := recoveryResumeGroupKey(legacy[runStart], byID) |
| 325 | runEnd := runStart + 1 |
| 326 | runActivity := legacy[runStart].ModTime |
| 327 | for runEnd < len(legacy) && recoveryResumeGroupKey(legacy[runEnd], byID) == key { |
| 328 | if legacy[runEnd].ModTime.After(runActivity) { |
| 329 | runActivity = legacy[runEnd].ModTime |
| 330 | } |
| 331 | runEnd++ |
| 332 | } |
| 333 | // Both inputs arrive newest-first, so every canonical row newer than |
| 334 | // this run's latest activity precedes the run; ties keep the legacy row |
| 335 | // first so numeric indices of an unchanged legacy list stay put. |
| 336 | for nextCanonical < len(canonical) && canonical[nextCanonical].session.ModTime.After(runActivity) { |
| 337 | merged = append(merged, canonical[nextCanonical]) |
| 338 | nextCanonical++ |
| 339 | } |
| 340 | appendRun(legacy[runStart:runEnd]) |
| 341 | runStart = runEnd |
| 342 | } |
| 343 | for ; nextCanonical < len(canonical); nextCanonical++ { |
| 344 | merged = append(merged, canonical[nextCanonical]) |
| 345 | } |
| 346 | return capResumeEntries(merged, limit) |
| 347 | } |
| 348 | |
| 349 | // capResumeEntries limits the merged picker list while keeping recovery |
| 350 | // families intact at the display cap, mirroring capResumeSessionGroups over |
| 351 | // the entry shape that also carries canonical identities. |
| 352 | func capResumeEntries(entries []resumeEntry, limit int) []resumeEntry { |
| 353 | if limit <= 0 || len(entries) <= limit { |
| 354 | return entries |
| 355 | } |
| 356 | sessions := make([]agent.SessionInfo, len(entries)) |
| 357 | for i := range entries { |
| 358 | sessions[i] = entries[i].session |
| 359 | } |
| 360 | byID := make(map[string]agent.SessionInfo, len(sessions)) |
| 361 | for _, session := range sessions { |
| 362 | byID[agent.BranchID(session.Path)] = session |
| 363 | } |
| 364 | out := make([]resumeEntry, 0, limit) |
| 365 | for start := 0; start < len(entries); { |
| 366 | key := recoveryResumeGroupKey(entries[start].session, byID) |
| 367 | end := start + 1 |
| 368 | for end < len(entries) && recoveryResumeGroupKey(entries[end].session, byID) == key { |
| 369 | end++ |
| 370 | } |
| 371 | if len(out) > 0 && len(out)+(end-start) > limit { |
| 372 | break |
| 373 | } |
| 374 | out = append(out, entries[start:end]...) |
| 375 | start = end |
| 376 | if len(out) >= limit { |
| 377 | break |
| 378 | } |
| 379 | } |
| 380 | return out |
| 381 | } |
| 382 | |
| 383 | // newestResumeTarget returns the newest resumable conversation of a workspace |
| 384 | // across both stores. It backs --continue: like mostRecentSession it wants the |
| 385 | // chronologically newest conversation, not the picker's leaf-first family |
| 386 | // preference; migrated sources and engine mirrors defer to the legacy row |
| 387 | func newestResumeTarget(sessionDir string) (cliResumeTarget, bool) { |
| 388 | scan := scanWorkspaceResume(context.Background(), sessionDir) |
| 389 | var newestLegacy agent.SessionInfo |
| 390 | if len(scan.legacy) > 0 { |
| 391 | // ListSessions is newest-first, so the first visible row wins. |
| 392 | newestLegacy = scan.legacy[0] |
| 393 | } |
| 394 | var newestCanonical resumeEntry |
| 395 | if len(scan.canonical) > 0 { |
| 396 | newestCanonical = scan.canonical[0] |
| 397 | } |
| 398 | switch { |
| 399 | case newestLegacy.Path == "" && newestCanonical.target.empty(): |
| 400 | return cliResumeTarget{}, false |
| 401 | case newestLegacy.Path == "": |
| 402 | return newestCanonical.target, true |
| 403 | case newestCanonical.target.empty(): |
| 404 | return cliResumeTarget{path: newestLegacy.Path}, true |
| 405 | case newestCanonical.session.ModTime.After(newestLegacy.ModTime): |
| 406 | return newestCanonical.target, true |
| 407 | default: |
| 408 | return cliResumeTarget{path: newestLegacy.Path}, true |
| 409 | } |
| 410 | } |
| 411 |