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