返回 DeepSeek-Reasonix
sessions.go
根目录 / desktop / sessions.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "syscall"
14 "time"
15
16 "reasonix/internal/agent"
17 "reasonix/internal/config"
18 "reasonix/internal/fileutil"
19 filelock "reasonix/internal/identitylock"
20 "reasonix/internal/store"
21 )
22
23 // sessions.go holds the desktop-only session-management state that the shared
24 // kernel doesn't model: custom display titles. A session on disk is just a JSONL
25 // transcript named by timestamp+model, with no title slot — so the history panel
26 // stores user-chosen names in a sidecar map (basename → title) next to the .jsonl
27 // files. The preview (first user message) is the default name; a title overrides
28 // it. Deleting a session also drops its title entry.
29
30 const sessionTitlesFile = ".titles.json"
31 const sessionDisplayFile = ".display.json"
32 const sessionPlannerDisplayFile = ".planner-display.json"
33 const sessionTrashDir = ".trash"
34 const sessionTrashMetaFile = ".trash-meta.json"
35
36 const (
37 // Durable sidecar publication includes an fsync while holding the update
38 // lock. Keep enough queue budget for a burst of in-process writers on
39 // slower Windows disks, but fail external contention quickly so turn
40 // completion and retry-queue handoff do not stall behind another process.
41 sessionSidecarQueueTimeout = 5 * time.Second
42 sessionSidecarExternalLockTimeout = 750 * time.Millisecond
43 )
44
45 var (
46 sessionTitlesQueueTimeout = sessionSidecarQueueTimeout
47 sessionPlannerDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
48 sessionDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
49 )
50
51 func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
52 func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
53 func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
54
55 func desktopSessionDir(root string) string {
56 root = strings.TrimSpace(root)
57 if root == "" {
58 cwd, err := os.Getwd()
59 if err != nil {
60 return config.SessionDir()
61 }
62 root = cwd
63 }
64 if dir := config.ProjectSessionDir(root); dir != "" {
65 return dir
66 }
67 return config.SessionDir()
68 }
69
70 func loadSessionTitlesForUpdate(dir string) (map[string]string, error) {
71 return loadStringMapForUpdate(sessionTitlesPath(dir))
72 }
73
74 func updateSessionTitles(dir string, mutate func(map[string]string) bool) error {
75 if strings.TrimSpace(dir) == "" {
76 return errors.New("title directory is empty")
77 }
78 if err := os.MkdirAll(dir, 0o755); err != nil {
79 return err
80 }
81 ctx, cancel := context.WithTimeout(context.Background(), sessionTitlesQueueTimeout)
82 defer cancel()
83 release, err := filelock.AcquireWithExternalTimeout(ctx, sessionTitlesPath(dir)+".lock", sessionSidecarExternalLockTimeout)
84 if err != nil {
85 return fmt.Errorf("lock title sidecar: %w", err)
86 }
87 defer release()
88
89 m, err := loadSessionTitlesForUpdate(dir)
90 if err != nil {
91 return err
92 }
93 if !mutate(m) {
94 return nil
95 }
96 return saveSessionTitles(dir, m)
97 }
98
99 // saveSessionTitles writes the map durably and atomically. Keep this on the
100 // shared helper so the temporary file is fsynced before it is published.
101 func saveSessionTitles(dir string, m map[string]string) error {
102 b, err := json.MarshalIndent(m, "", " ")
103 if err != nil {
104 return err
105 }
106 if err := os.MkdirAll(dir, 0o755); err != nil {
107 return err
108 }
109 return fileutil.AtomicWriteFile(sessionTitlesPath(dir), b, 0o600)
110 }
111
112 type sessionTrashArtifact struct {
113 src string
114 name string
115 }
116
117 func sessionTelemetryPath(sessionPath string) string {
118 if strings.TrimSpace(sessionPath) == "" {
119 return ""
120 }
121 return sessionPath + ".telemetry.json"
122 }
123
124 // errSessionBusyElsewhere is the sanitized error surfaced when a destructive
125 // session operation is blocked by a live owner. It intentionally carries no
126 // writer id, hostname, or path.
127 var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")
128
129 // acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
130 // the sanitized busy error. The guard holds the session's save and lease
131 // locks across the destructive operation and deletes the lock files
132 // atomically with the release — a one-shot busy probe followed by RemoveAll
133 // would let another process acquire the lease in between and then lose its
134 // freshly locked lease file, breaking cross-process mutual exclusion.
135 func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
136 guard, err := withSessionLeaseContentionRetry(func() (*agent.SessionRemovalGuard, error) {
137 return agent.TryAcquireSessionRemovalGuard(sessionPath)
138 })
139 if err != nil {
140 if errors.Is(err, agent.ErrSessionLeaseHeld) {
141 return nil, errSessionBusyElsewhere
142 }
143 return nil, err
144 }
145 return guard, nil
146 }
147
148 func sessionOwnedArtifactPaths(sessionPath string) []string {
149 key := filepath.Base(sessionPath)
150 artifacts := sessionTrashArtifacts(sessionPath, key)
151 paths := make([]string, 0, len(artifacts))
152 for _, artifact := range artifacts {
153 if strings.TrimSpace(artifact.src) != "" {
154 paths = append(paths, artifact.src)
155 }
156 }
157 return paths
158 }
159
160 func trashSessionArtifacts(dir, sessionPath, key string) error {
161 return trashSessionArtifactsBeforeMove(dir, sessionPath, key, nil)
162 }
163
164 func reconcileDesktopCleanupPending(dir string) error {
165 return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
166 if strings.TrimSpace(item.Meta.Operation) == "delete" {
167 sessionPath, key, err := validateSessionPath(dir, item.SessionPath)
168 if err != nil {
169 return err
170 }
171 return reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key)
172 }
173 return removeDesktopSessionArtifacts(item.SessionPath)
174 })
175 }
176
177 func validateSessionTrashTarget(dir, sessionPath, key string) error {
178 if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
179 return nil
180 } else if err != nil {
181 return err
182 }
183 itemDir := filepath.Join(sessionTrashPath(dir), key)
184 if info, err := os.Stat(itemDir); err == nil {
185 if !info.IsDir() {
186 return fmt.Errorf("session trash target is not a directory: %s", key)
187 }
188 trashPath := filepath.Join(itemDir, key)
189 if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
190 removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
191 if err != nil {
192 return err
193 }
194 if removable {
195 return nil
196 }
197 if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
198 return errSessionBusyElsewhere
199 }
200 return nil
201 } else if err != nil && !os.IsNotExist(err) {
202 return err
203 }
204 return nil
205 } else if !os.IsNotExist(err) {
206 return err
207 }
208 return nil
209 }
210
211 type preparedSessionTrashTarget struct {
212 shouldMove bool
213 itemDir string
214 allocateUnique bool
215 }
216
217 func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
218 if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
219 return preparedSessionTrashTarget{}, nil
220 } else if err != nil {
221 return preparedSessionTrashTarget{}, err
222 }
223 itemDir := filepath.Join(sessionTrashPath(dir), key)
224 if info, err := os.Stat(itemDir); err == nil {
225 if !info.IsDir() {
226 return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
227 }
228 trashPath := filepath.Join(itemDir, key)
229 if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
230 removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
231 if err != nil {
232 return preparedSessionTrashTarget{}, err
233 }
234 if removable {
235 return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
236 }
237 if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
238 return preparedSessionTrashTarget{}, errSessionBusyElsewhere
239 }
240 return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
241 } else if err != nil && !os.IsNotExist(err) {
242 return preparedSessionTrashTarget{}, err
243 }
244 if err := os.RemoveAll(itemDir); err != nil {
245 return preparedSessionTrashTarget{}, err
246 }
247 } else if !os.IsNotExist(err) {
248 return preparedSessionTrashTarget{}, err
249 }
250 return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
251 }
252
253 func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
254 root := sessionTrashPath(dir)
255 if err := os.MkdirAll(root, 0o755); err != nil {
256 return "", err
257 }
258 stem := strings.TrimSuffix(key, ".jsonl")
259 for i := range 100 {
260 name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
261 itemDir := filepath.Join(root, name)
262 if err := os.Mkdir(itemDir, 0o755); err == nil {
263 return itemDir, nil
264 } else if !os.IsExist(err) {
265 return "", err
266 }
267 }
268 return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
269 }
270
271 // liveSessionRemovableWithExistingTrash reports whether a live session file may
272 // be removed even though a trash copy already exists under the same key: the
273 // live file must be discardable (empty stub) or byte-identical to the trash
274 // copy, and no other runtime may hold its session lease — another process could
275 // be mid-write, and removing the file would silently drop its next save.
276 func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
277 discardable, err := liveSessionDiscardable(sessionPath)
278 if err != nil {
279 return false, err
280 }
281 duplicate := false
282 if !discardable {
283 duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
284 if err != nil {
285 return false, err
286 }
287 }
288 if !discardable && !duplicate {
289 return false, nil
290 }
291 return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
292 }
293
294 func liveSessionDiscardable(sessionPath string) (bool, error) {
295 if agent.IsCleanupPending(sessionPath) {
296 return true, nil
297 }
298 return liveSessionContentDiscardable(sessionPath)
299 }
300
301 func liveSessionContentDiscardable(sessionPath string) (bool, error) {
302 info, err := os.Stat(sessionPath)
303 if os.IsNotExist(err) {
304 return true, nil
305 }
306 if err != nil {
307 return false, err
308 }
309 if info.IsDir() {
310 return false, nil
311 }
312 if info.Size() == 0 {
313 return true, nil
314 }
315 session, err := agent.LoadSession(sessionPath)
316 if err != nil {
317 return false, nil
318 }
319 return !session.HasContent(), nil
320 }
321
322 func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
323 if _, err := os.Stat(sessionPath); err != nil {
324 if os.IsNotExist(err) {
325 return true, nil
326 }
327 return false, err
328 }
329 // Compare decoded transcripts, not .jsonl bytes: the checkpoint only
330 // changes at checkpoints, so two byte-identical .jsonl files can hide
331 // diverged event logs — and treating them as duplicates would delete the
332 // live session's newer history.
333 return agent.SessionsShareContent(sessionPath, trashPath)
334 }
335
336 func sessionFileHasConversationContent(sessionPath string) bool {
337 if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
338 return false
339 }
340 info, err := os.Stat(sessionPath)
341 if err != nil || info.IsDir() || info.Size() == 0 {
342 return false
343 }
344 session, err := agent.LoadSession(sessionPath)
345 if err != nil {
346 return false
347 }
348 return session.HasContent()
349 }
350
351 func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove func()) error {
352 if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
353 return err
354 }
355 target, err := prepareSessionTrashTarget(dir, sessionPath, key)
356 if err != nil {
357 return err
358 }
359 if !target.shouldMove {
360 return agent.ClearCleanupPending(sessionPath)
361 }
362 // Acquired after prepareSessionTrashTarget: the duplicate-trash path in
363 // there takes its own removal guard, and the guard is not reentrant.
364 guard, err := acquireSessionRemovalGuard(sessionPath)
365 if err != nil {
366 return err
367 }
368 defer guard.Release()
369 if err := invalidateTopicDirMarkers(dir); err != nil {
370 return err
371 }
372 itemDir := target.itemDir
373 if target.allocateUnique {
374 itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
375 if err != nil {
376 return err
377 }
378 } else if err := os.MkdirAll(itemDir, 0o755); err != nil {
379 return err
380 }
381 if beforeMove != nil {
382 beforeMove()
383 }
384 for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
385 if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
386 return err
387 }
388 }
389 if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
390 return err
391 }
392 if err := guard.RemoveSidecarsAndRelease(); err != nil {
393 return err
394 }
395 meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli(), Kind: "deleted"}
396 b, err := json.MarshalIndent(meta, "", " ")
397 if err != nil {
398 return err
399 }
400 if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
401 return err
402 }
403 if err := agent.ClearCleanupPending(sessionPath); err != nil {
404 return err
405 }
406 return nil
407 }
408
409 func listTrashedSessionFiles(dir string) ([]string, error) {
410 root := sessionTrashPath(dir)
411 entries, err := os.ReadDir(root)
412 if err != nil {
413 if os.IsNotExist(err) {
414 return []string{}, nil
415 }
416 return nil, err
417 }
418 paths := []string{}
419 for _, e := range entries {
420 if !e.IsDir() {
421 continue
422 }
423 itemDir := filepath.Join(root, e.Name())
424 keys := []string{}
425 if b, err := readFileUTF8(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
426 var meta trashedSessionMeta
427 if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
428 keys = append(keys, meta.Key)
429 }
430 }
431 if store.IsSessionTranscriptName(e.Name()) {
432 keys = append(keys, e.Name())
433 }
434 for _, key := range keys {
435 path := filepath.Join(itemDir, key)
436 validPath, _, _, err := validateTrashedSessionPath(dir, path)
437 if err != nil {
438 continue
439 }
440 if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
441 paths = append(paths, validPath)
442 break
443 }
444 }
445 }
446 return paths, nil
447 }
448
449 func trashedSessionDeletedAt(path string) int64 {
450 b, err := readFileUTF8(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
451 if err != nil {
452 return 0
453 }
454 var meta trashedSessionMeta
455 if err := json.Unmarshal(b, &meta); err != nil {
456 return 0
457 }
458 return meta.DeletedAt
459 }
460
461 func purgeTrashedSessionFile(dir, path string) error {
462 _, key, itemDir, err := validateTrashedSessionPath(dir, path)
463 if err != nil {
464 return err
465 }
466 if err := os.RemoveAll(itemDir); err != nil {
467 return err
468 }
469 if err := updateSessionTitles(dir, func(m map[string]string) bool {
470 if _, ok := m[key]; !ok {
471 return false
472 }
473 delete(m, key)
474 return true
475 }); err != nil {
476 return err
477 }
478 if err := removeSessionDisplayKey(dir, key); err != nil {
479 return err
480 }
481 if err := removeSessionPlannerDisplay(dir, key); err != nil {
482 return err
483 }
484 return nil
485 }
486
487 func movePathIfExists(src, dst string) error {
488 if _, err := os.Lstat(src); os.IsNotExist(err) {
489 return nil
490 } else if err != nil {
491 return err
492 }
493 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
494 return err
495 }
496 // Try os.Rename first — it's atomic and fast when it works.
497 if err := os.Rename(src, dst); err == nil {
498 return nil
499 } else if sourcePathMissing(src) {
500 return nil
501 } else if !isRenameCrossDeviceOrBusy(err) {
502 return err
503 }
504 // Fallback: copy then remove. This handles cross-device moves and the
505 // Windows case where a directory rename fails because a handle is briefly
506 // held open (e.g. antivirus scan, indexing, or a just-closed file).
507 return copyAndRemove(src, dst)
508 }
509
510 // isRenameCrossDeviceOrBusy reports whether err is a cross-device rename or
511 // a "file busy" error that a copy+remove fallback can recover from.
512 func isRenameCrossDeviceOrBusy(err error) bool {
513 if err == nil {
514 return false
515 }
516 // Cross-device link.
517 le := &os.LinkError{}
518 if errors.As(err, &le) {
519 if errors.Is(le.Err, syscall.EXDEV) {
520 return true
521 }
522 // Windows: "The process cannot access the file because it is being used by another process."
523 var errno syscall.Errno
524 if errors.As(le.Err, &errno) {
525 return errno == 32 // ERROR_SHARING_VIOLATION
526 }
527 }
528 return false
529 }
530
531 func sourcePathMissing(src string) bool {
532 if strings.TrimSpace(src) == "" {
533 return true
534 }
535 _, err := os.Lstat(src)
536 return os.IsNotExist(err)
537 }
538
539 // copyPathFn is a seam for tests to simulate a source vanishing mid-copy.
540 var copyPathFn = copyPath
541
542 // copyAndRemove recursively copies src to dst, then removes src. Used as a
543 // fallback when os.Rename fails (cross-device or Windows file-lock races).
544 func copyAndRemove(src, dst string) error {
545 if err := copyPathFn(src, dst); err != nil {
546 if sourcePathMissing(src) {
547 // The source vanished mid-copy; drop the partial destination so
548 // the trash never keeps a truncated artifact that a later restore
549 // would resurrect as a corrupted transcript.
550 _ = os.RemoveAll(dst)
551 return nil
552 }
553 return err
554 }
555 // On Windows, wait briefly for any file handle release.
556 time.Sleep(10 * time.Millisecond)
557 return os.RemoveAll(src)
558 }
559
560 func copyPath(src, dst string) error {
561 info, err := os.Lstat(src)
562 if err != nil {
563 if os.IsNotExist(err) {
564 return nil
565 }
566 return err
567 }
568 mode := info.Mode()
569 switch {
570 case mode&os.ModeSymlink != 0:
571 return copySymlink(src, dst)
572 case mode.IsDir():
573 return copyDir(src, dst, mode.Perm())
574 case mode.IsRegular():
575 return copyFile(src, dst, mode.Perm())
576 default:
577 return fmt.Errorf("unsupported file type in rename fallback: %s", src)
578 }
579 }
580
581 func copyDir(src, dst string, mode os.FileMode) error {
582 if err := os.MkdirAll(dst, mode); err != nil {
583 return err
584 }
585 entries, err := os.ReadDir(src)
586 if err != nil {
587 if os.IsNotExist(err) {
588 _ = os.RemoveAll(dst)
589 return nil
590 }
591 return err
592 }
593 for _, e := range entries {
594 srcPath := filepath.Join(src, e.Name())
595 dstPath := filepath.Join(dst, e.Name())
596 if err := copyPath(srcPath, dstPath); err != nil {
597 return err
598 }
599 }
600 return nil
601 }
602
603 func copyFile(src, dst string, mode os.FileMode) error {
604 // Open source file.
605 in, err := os.Open(src)
606 if err != nil {
607 if os.IsNotExist(err) {
608 return nil
609 }
610 return err
611 }
612 // Create destination file.
613 out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
614 if err != nil {
615 in.Close()
616 return err
617 }
618 // Copy content.
619 _, err = io.Copy(out, in)
620 // Close both files before any removal.
621 closeErr := out.Close()
622 in.Close()
623 if err != nil {
624 return err
625 }
626 if closeErr != nil {
627 return closeErr
628 }
629 return nil
630 }
631
632 func copySymlink(src, dst string) error {
633 target, err := os.Readlink(src)
634 if err != nil {
635 if os.IsNotExist(err) {
636 return nil
637 }
638 return err
639 }
640 return os.Symlink(target, dst)
641 }
642
643 func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
644 artifacts, err := agent.ListSubagentsByParent(dir, agent.BranchID(sessionPath))
645 if err != nil {
646 return err
647 }
648 trashSubagentDir := filepath.Join(itemDir, "subagents")
649 for _, artifact := range artifacts {
650 paths := []string{artifact.SessionPath, artifact.MetaPath}
651 paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
652 for _, src := range paths {
653 if strings.TrimSpace(src) == "" {
654 continue
655 }
656 if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
657 return err
658 }
659 }
660 }
661 return nil
662 }
663
664 func checkRestoreSubagentConflicts(dir, itemDir string) error {
665 trashSubagentDir := filepath.Join(itemDir, "subagents")
666 entries, err := os.ReadDir(trashSubagentDir)
667 if err != nil {
668 if os.IsNotExist(err) {
669 return nil
670 }
671 return err
672 }
673 for _, entry := range entries {
674 if entry.IsDir() {
675 continue
676 }
677 target := filepath.Join(dir, "subagents", entry.Name())
678 if _, err := os.Stat(target); err == nil {
679 return fmt.Errorf("subagent artifact already exists: %s", entry.Name())
680 } else if !os.IsNotExist(err) {
681 return err
682 }
683 }
684 return nil
685 }
686
687 func restoreSubagentArtifacts(dir, itemDir string) error {
688 trashSubagentDir := filepath.Join(itemDir, "subagents")
689 entries, err := os.ReadDir(trashSubagentDir)
690 if err != nil {
691 if os.IsNotExist(err) {
692 return nil
693 }
694 return err
695 }
696 for _, entry := range entries {
697 if entry.IsDir() {
698 continue
699 }
700 if err := movePathIfExists(filepath.Join(trashSubagentDir, entry.Name()), filepath.Join(dir, "subagents", entry.Name())); err != nil {
701 return err
702 }
703 }
704 return nil
705 }
706
707 func validateSessionPath(dir, sessionPath string) (string, string, error) {
708 if strings.TrimSpace(sessionPath) == "" {
709 return "", "", fmt.Errorf("empty session path")
710 }
711 absDir, err := filepath.Abs(dir)
712 if err != nil {
713 return "", "", err
714 }
715 path := sessionPath
716 if !filepath.IsAbs(path) {
717 path = filepath.Join(absDir, path)
718 }
719 absPath, err := filepath.Abs(path)
720 if err != nil {
721 return "", "", err
722 }
723 if !isLegacySessionTranscriptName(filepath.Base(absPath)) {
724 return "", "", fmt.Errorf("not a session file: %s", sessionPath)
725 }
726 rel, err := filepath.Rel(absDir, absPath)
727 if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
728 return "", "", fmt.Errorf("session path outside session dir: %s", sessionPath)
729 }
730 if info, err := os.Lstat(absPath); err == nil {
731 if info.IsDir() {
732 return "", "", fmt.Errorf("not a session file: %s", sessionPath)
733 }
734 realDir, dirErr := filepath.EvalSymlinks(absDir)
735 if dirErr != nil {
736 realDir = absDir
737 }
738 realPath, err := filepath.EvalSymlinks(absPath)
739 if err != nil {
740 return "", "", err
741 }
742 rel, err := filepath.Rel(realDir, realPath)
743 if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
744 return "", "", fmt.Errorf("session path escapes session dir: %s", sessionPath)
745 }
746 } else if !os.IsNotExist(err) {
747 return "", "", err
748 }
749 return absPath, filepath.Base(absPath), nil
750 }
751
752 func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
753 if strings.TrimSpace(sessionPath) == "" {
754 return "", "", "", fmt.Errorf("empty session path")
755 }
756 root, err := filepath.Abs(sessionTrashPath(dir))
757 if err != nil {
758 return "", "", "", err
759 }
760 path := sessionPath
761 if !filepath.IsAbs(path) {
762 path = filepath.Join(root, path)
763 }
764 absPath, err := filepath.Abs(path)
765 if err != nil {
766 return "", "", "", err
767 }
768 if !isLegacySessionTranscriptName(filepath.Base(absPath)) {
769 return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
770 }
771 rel, err := filepath.Rel(root, absPath)
772 if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
773 return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
774 }
775 parts := strings.Split(rel, string(filepath.Separator))
776 if len(parts) != 2 {
777 return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
778 }
779 if parts[0] != parts[1] {
780 b, err := readFileUTF8(filepath.Join(root, parts[0], sessionTrashMetaFile))
781 if err != nil {
782 return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
783 }
784 var meta trashedSessionMeta
785 if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
786 return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
787 }
788 }
789 if info, err := os.Lstat(absPath); err == nil {
790 if info.IsDir() {
791 return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
792 }
793 realRoot, dirErr := filepath.EvalSymlinks(root)
794 if dirErr != nil {
795 realRoot = root
796 }
797 realPath, err := filepath.EvalSymlinks(absPath)
798 if err != nil {
799 return "", "", "", err
800 }
801 rel, err := filepath.Rel(realRoot, realPath)
802 if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
803 return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
804 }
805 } else if !os.IsNotExist(err) {
806 return "", "", "", err
807 }
808 return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
809 }
810
811 type sessionDisplayMap map[string]map[string]string
812
813 type sessionPlannerDisplayMap map[string][]plannerDisplayTurn
814
815 type plannerDisplayTurn struct {
816 TurnID string `json:"turnId,omitempty"`
817 UserHash string `json:"userHash"`
818 Messages []HistoryMessage `json:"messages"`
819 }
820
821 var errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")
822
823 // sessionPlannerDisplayUpdateAfterLoad is a subprocess-test seam. Production
824 // leaves it nil; tests use it to force two independent processes into the old
825 // stale read-modify-write window without relying on scheduler timing.
826 var sessionPlannerDisplayUpdateAfterLoad func()
827
828 func messageDisplayKey(content string) string {
829 sum := sha256.Sum256([]byte(content))
830 return fmt.Sprintf("%x", sum[:])
831 }
832
833 func loadSessionDisplays(dir string) sessionDisplayMap {
834 m := sessionDisplayMap{}
835 b, err := readFileUTF8(sessionDisplayPath(dir))
836 if err != nil {
837 return m
838 }
839 _ = json.Unmarshal(b, &m)
840 return m
841 }
842
843 func sessionPlannerDisplayPath(dir string) string {
844 return filepath.Join(dir, sessionPlannerDisplayFile)
845 }
846
847 func loadSessionPlannerDisplays(dir string) sessionPlannerDisplayMap {
848 m := sessionPlannerDisplayMap{}
849 if strings.TrimSpace(dir) == "" {
850 return m
851 }
852 b, err := readFileUTF8(sessionPlannerDisplayPath(dir))
853 if err != nil {
854 return m
855 }
856 _ = json.Unmarshal(b, &m)
857 return m
858 }
859
860 func loadSessionPlannerDisplaysForUpdate(dir string) (sessionPlannerDisplayMap, error) {
861 m := sessionPlannerDisplayMap{}
862 b, err := readFileUTF8(sessionPlannerDisplayPath(dir))
863 if err != nil {
864 if errors.Is(err, os.ErrNotExist) {
865 return m, nil
866 }
867 return nil, err
868 }
869 if err := json.Unmarshal(b, &m); err != nil {
870 return nil, fmt.Errorf("%w: %w", errCorruptSessionPlannerDisplay, err)
871 }
872 if m == nil {
873 m = sessionPlannerDisplayMap{}
874 }
875 return m, nil
876 }
877
878 func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
879 b, err := json.MarshalIndent(m, "", " ")
880 if err != nil {
881 return err
882 }
883 if err := os.MkdirAll(dir, 0o755); err != nil {
884 return err
885 }
886 return fileutil.AtomicWriteFile(sessionPlannerDisplayPath(dir), b, 0o600)
887 }
888
889 func saveOrRemoveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
890 if len(m) == 0 {
891 err := os.Remove(sessionPlannerDisplayPath(dir))
892 if os.IsNotExist(err) {
893 return nil
894 }
895 return err
896 }
897 return saveSessionPlannerDisplays(dir, m)
898 }
899
900 func updateSessionPlannerDisplays(dir string, recoverCorrupt bool, mutate func(sessionPlannerDisplayMap) bool) error {
901 if strings.TrimSpace(dir) == "" {
902 return errors.New("planner display directory is empty")
903 }
904 if err := os.MkdirAll(dir, 0o755); err != nil {
905 return err
906 }
907 ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
908 defer cancel()
909 release, err := filelock.AcquireWithExternalTimeout(ctx, sessionPlannerDisplayPath(dir)+".lock", sessionPlannerDisplayExternalLockTimeout)
910 if err != nil {
911 return fmt.Errorf("lock planner display sidecar: %w", err)
912 }
913 defer release()
914
915 m, err := loadSessionPlannerDisplaysForUpdate(dir)
916 if err != nil {
917 if !recoverCorrupt || !errors.Is(err, errCorruptSessionPlannerDisplay) {
918 return err
919 }
920 // A corrupt shared map cannot be edited safely. Destructive cleanup is
921 // allowed to retire the unreadable sidecar so deleted-session display
922 // data does not linger and later records can start from a valid map.
923 if removeErr := os.Remove(sessionPlannerDisplayPath(dir)); removeErr != nil && !os.IsNotExist(removeErr) {
924 return errors.Join(err, removeErr)
925 }
926 m = sessionPlannerDisplayMap{}
927 }
928 if sessionPlannerDisplayUpdateAfterLoad != nil {
929 sessionPlannerDisplayUpdateAfterLoad()
930 }
931 if !mutate(m) {
932 return nil
933 }
934 return saveOrRemoveSessionPlannerDisplays(dir, m)
935 }
936
937 func recordSessionPlannerDisplay(dir, sessionPath, userContent string, messages []HistoryMessage) error {
938 return recordSessionPlannerDisplayForTurn(dir, sessionPath, "", userContent, messages)
939 }
940
941 func recordSessionPlannerDisplayForTurn(dir, sessionPath, turnID, userContent string, messages []HistoryMessage) error {
942 if strings.TrimSpace(sessionPath) == "" || strings.TrimSpace(userContent) == "" || len(messages) == 0 {
943 return nil
944 }
945 key := filepath.Base(sessionPath)
946 turn := plannerDisplayTurn{
947 TurnID: strings.TrimSpace(turnID),
948 UserHash: messageDisplayKey(userContent),
949 Messages: cloneHistoryMessages(messages),
950 }
951 return updateSessionPlannerDisplays(dir, false, func(m sessionPlannerDisplayMap) bool {
952 if turn.TurnID != "" {
953 for i := range m[key] {
954 if m[key][i].TurnID == turn.TurnID {
955 m[key][i] = turn
956 return true
957 }
958 }
959 }
960 m[key] = append(m[key], turn)
961 return true
962 })
963 }
964
965 func removeSessionPlannerDisplay(dir, sessionPath string) error {
966 if strings.TrimSpace(sessionPath) == "" {
967 return nil
968 }
969 key := filepath.Base(sessionPath)
970 return updateSessionPlannerDisplays(dir, true, func(m sessionPlannerDisplayMap) bool {
971 if _, ok := m[key]; !ok {
972 return false
973 }
974 delete(m, key)
975 return true
976 })
977 }
978
979 func pruneSessionPlannerDisplays(dir string, protected map[string]struct{}) error {
980 return updateSessionPlannerDisplays(dir, true, func(m sessionPlannerDisplayMap) bool {
981 changed := false
982 for key := range m {
983 if sessionDisplayKeyStillOwned(dir, key, protected) {
984 continue
985 }
986 delete(m, key)
987 changed = true
988 }
989 return changed
990 })
991 }
992
993 func sessionPlannerDisplayTurns(dir, sessionPath string) []plannerDisplayTurn {
994 if strings.TrimSpace(dir) == "" || strings.TrimSpace(sessionPath) == "" {
995 return nil
996 }
997 turns := loadSessionPlannerDisplays(dir)[filepath.Base(sessionPath)]
998 if len(turns) == 0 {
999 return nil
1000 }
1001 out := make([]plannerDisplayTurn, 0, len(turns))
1002 for _, turn := range turns {
1003 if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
1004 continue
1005 }
1006 out = append(out, plannerDisplayTurn{
1007 TurnID: turn.TurnID,
1008 UserHash: turn.UserHash,
1009 Messages: cloneHistoryMessages(turn.Messages),
1010 })
1011 }
1012 return out
1013 }
1014
1015 func saveSessionDisplays(dir string, m sessionDisplayMap) error {
1016 b, err := json.MarshalIndent(m, "", " ")
1017 if err != nil {
1018 return err
1019 }
1020 if err := os.MkdirAll(dir, 0o755); err != nil {
1021 return err
1022 }
1023 return fileutil.AtomicWriteFile(sessionDisplayPath(dir), b, 0o600)
1024 }
1025
1026 func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
1027 if len(m) == 0 {
1028 err := os.Remove(sessionDisplayPath(dir))
1029 if os.IsNotExist(err) {
1030 return nil
1031 }
1032 return err
1033 }
1034 return saveSessionDisplays(dir, m)
1035 }
1036
1037 // updateSessionDisplays serializes the display sidecar's read-modify-write
1038 // cycle. Parallel tabs can record display text concurrently; atomic rename
1039 // protects readers from partial JSON but cannot prevent the last writer from
1040 // replacing another tab's freshly added keys (#6873).
1041 func updateSessionDisplays(dir string, mutate func(sessionDisplayMap) bool) error {
1042 if strings.TrimSpace(dir) == "" {
1043 return errors.New("display directory is empty")
1044 }
1045 if err := os.MkdirAll(dir, 0o755); err != nil {
1046 return err
1047 }
1048 ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
1049 defer cancel()
1050 release, err := filelock.AcquireWithExternalTimeout(ctx, sessionDisplayPath(dir)+".lock", sessionDisplayExternalLockTimeout)
1051 if err != nil {
1052 return fmt.Errorf("lock display sidecar: %w", err)
1053 }
1054 defer release()
1055
1056 m := loadSessionDisplays(dir)
1057 if !mutate(m) {
1058 return nil
1059 }
1060 return saveOrRemoveSessionDisplays(dir, m)
1061 }
1062
1063 func removeSessionDisplayKey(dir, key string) error {
1064 key = strings.TrimSpace(key)
1065 if key == "" {
1066 return nil
1067 }
1068 return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
1069 if m[key] == nil {
1070 return false
1071 }
1072 delete(m, key)
1073 return true
1074 })
1075 }
1076
1077 func removeSessionDisplay(dir, sessionPath string) error {
1078 if strings.TrimSpace(sessionPath) == "" {
1079 return nil
1080 }
1081 return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
1082 }
1083
1084 func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
1085 return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
1086 if len(m) == 0 {
1087 return false
1088 }
1089 changed := false
1090 for key := range m {
1091 if sessionDisplayKeyStillOwned(dir, key, protected) {
1092 continue
1093 }
1094 delete(m, key)
1095 changed = true
1096 }
1097 return changed
1098 })
1099 }
1100
1101 func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
1102 key = strings.TrimSpace(key)
1103 if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
1104 return false
1105 }
1106 if protected != nil {
1107 if _, ok := protected[key]; ok {
1108 return true
1109 }
1110 }
1111 sessionPath := filepath.Join(dir, key)
1112 if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
1113 return true
1114 }
1115 trashPath := filepath.Join(sessionTrashPath(dir), key, key)
1116 if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
1117 return true
1118 }
1119 if paths, err := listTrashedSessionFiles(dir); err == nil {
1120 for _, path := range paths {
1121 if filepath.Base(path) == key {
1122 return true
1123 }
1124 }
1125 }
1126 return false
1127 }
1128
1129 func recordSessionDisplay(dir, sessionPath, content, display string) error {
1130 if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
1131 return nil
1132 }
1133 return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
1134 key := filepath.Base(sessionPath)
1135 if m[key] == nil {
1136 m[key] = map[string]string{}
1137 }
1138 m[key][messageDisplayKey(content)] = display
1139 return true
1140 })
1141 }
1142
1143 // sessionDisplayResolver loads the sidecar once and returns a per-message
1144 // resolver, so a transcript of N messages doesn't re-read .display.json N times.
1145 func sessionDisplayResolver(dir, sessionPath string) func(content string) string {
1146 return sessionDisplayResolverFromMap(loadSessionDisplays(dir), sessionPath)
1147 }
1148
1149 func sessionDisplayResolverFromMap(displays sessionDisplayMap, sessionPath string) func(content string) string {
1150 byHash := displays[filepath.Base(sessionPath)]
1151 return func(content string) string {
1152 if byHash != nil {
1153 if display := byHash[messageDisplayKey(content)]; strings.TrimSpace(display) != "" {
1154 return display
1155 }
1156 }
1157 return historyReplayUserContent(content)
1158 }
1159 }
1160
1161 func resolveSessionDisplay(dir, sessionPath, content string) string {
1162 return sessionDisplayResolver(dir, sessionPath)(content)
1163 }
1164
1164 lines GO