返回 DeepSeek-Reasonix
resume_catalog.go
根目录 / internal / cli / resume_catalog.go
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
411 lines GO