1167 lines
33 KiB
Go
1167 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/fileutil"
|
|
"reasonix/internal/store"
|
|
)
|
|
|
|
// sessions.go holds the desktop-only session-management state that the shared
|
|
// kernel doesn't model: custom display titles. A session on disk is just a JSONL
|
|
// transcript named by timestamp+model, with no title slot — so the history panel
|
|
// stores user-chosen names in a sidecar map (basename → title) next to the .jsonl
|
|
// files. The preview (first user message) is the default name; a title overrides
|
|
// it. Deleting a session also drops its title entry.
|
|
|
|
const sessionTitlesFile = ".titles.json"
|
|
const sessionDisplayFile = ".display.json"
|
|
const sessionPlannerDisplayFile = ".planner-display.json"
|
|
const sessionTrashDir = ".trash"
|
|
const sessionTrashMetaFile = ".trash-meta.json"
|
|
|
|
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
|
|
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
|
|
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
|
|
|
|
func desktopSessionDir(root string) string {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return config.SessionDir()
|
|
}
|
|
root = cwd
|
|
}
|
|
if dir := config.ProjectSessionDir(root); dir != "" {
|
|
return dir
|
|
}
|
|
return config.SessionDir()
|
|
}
|
|
|
|
// loadSessionTitles reads the basename→title map (missing/corrupt → empty).
|
|
func loadSessionTitles(dir string) map[string]string {
|
|
m := map[string]string{}
|
|
b, err := readFileWithTimeout(sessionTitlesPath(dir), topicFileReadTimeout)
|
|
if err != nil {
|
|
return m
|
|
}
|
|
_ = json.Unmarshal(b, &m)
|
|
return m
|
|
}
|
|
|
|
func loadSessionTitlesForUpdate(dir string) (map[string]string, error) {
|
|
return loadStringMapForUpdate(sessionTitlesPath(dir))
|
|
}
|
|
|
|
// saveSessionTitles writes the map atomically (temp file + rename).
|
|
func saveSessionTitles(dir string, m map[string]string) error {
|
|
b, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".titles.*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return fileutil.ReplaceFile(tmpPath, sessionTitlesPath(dir))
|
|
}
|
|
|
|
// setSessionTitle sets (or, with an empty title, clears) a session's custom name.
|
|
func setSessionTitle(dir, sessionPath, title string) error {
|
|
sessionPath, _, err := validateSessionPath(dir, sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := loadSessionTitlesForUpdate(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := filepath.Base(sessionPath)
|
|
if strings.TrimSpace(title) == "" {
|
|
delete(m, key)
|
|
} else {
|
|
m[key] = strings.TrimSpace(title)
|
|
}
|
|
return saveSessionTitles(dir, m)
|
|
}
|
|
|
|
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
|
|
// trash. Title/display sidecars stay in place so trash previews and restores can
|
|
// preserve the user's labels.
|
|
func deleteSessionFile(dir, sessionPath string) error {
|
|
sessionPath, key, err := validateSessionPath(dir, sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return trashSessionArtifacts(dir, sessionPath, key)
|
|
}
|
|
|
|
type trashedSessionMeta struct {
|
|
Key string `json:"key"`
|
|
DeletedAt int64 `json:"deletedAt"`
|
|
}
|
|
|
|
type sessionTrashArtifact struct {
|
|
src string
|
|
name string
|
|
}
|
|
|
|
func sessionTelemetryPath(sessionPath string) string {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return ""
|
|
}
|
|
return sessionPath + ".telemetry.json"
|
|
}
|
|
|
|
func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
|
|
stem := strings.TrimSuffix(key, ".jsonl")
|
|
return []sessionTrashArtifact{
|
|
{src: sessionPath, name: key},
|
|
{src: store.SessionMeta(sessionPath), name: key + ".meta"},
|
|
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
|
|
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
|
|
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
|
|
{src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"},
|
|
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
|
|
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
|
|
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
|
|
}
|
|
}
|
|
|
|
// errSessionBusyElsewhere is the sanitized error surfaced when a destructive
|
|
// session operation is blocked by a live owner. It intentionally carries no
|
|
// writer id, hostname, or path.
|
|
var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")
|
|
|
|
// acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
|
|
// the sanitized busy error. The guard holds the session's save and lease
|
|
// locks across the destructive operation and deletes the lock files
|
|
// atomically with the release — a one-shot busy probe followed by RemoveAll
|
|
// would let another process acquire the lease in between and then lose its
|
|
// freshly locked lease file, breaking cross-process mutual exclusion.
|
|
func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
|
|
guard, err := agent.TryAcquireSessionRemovalGuard(sessionPath)
|
|
if err != nil {
|
|
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return nil, errSessionBusyElsewhere
|
|
}
|
|
return nil, err
|
|
}
|
|
return guard, nil
|
|
}
|
|
|
|
func sessionOwnedArtifactPaths(sessionPath string) []string {
|
|
key := filepath.Base(sessionPath)
|
|
artifacts := sessionTrashArtifacts(sessionPath, key)
|
|
paths := make([]string, 0, len(artifacts))
|
|
for _, artifact := range artifacts {
|
|
if strings.TrimSpace(artifact.src) != "" {
|
|
paths = append(paths, artifact.src)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func trashSessionArtifacts(dir, sessionPath, key string) error {
|
|
return trashSessionArtifactsBeforeMove(dir, sessionPath, key, nil)
|
|
}
|
|
|
|
func reconcileDesktopCleanupPending(dir string) error {
|
|
return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
|
|
if strings.TrimSpace(item.Meta.Operation) == "delete" {
|
|
sessionPath, key, err := validateSessionPath(dir, item.SessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key)
|
|
}
|
|
return removeDesktopSessionArtifacts(item.SessionPath)
|
|
})
|
|
}
|
|
|
|
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
|
|
// Hold the removal guard across the whole move so no runtime can acquire
|
|
// the session (or save into it) while its artifacts are relocated; the
|
|
// lock sidecars are deleted atomically with the guard release.
|
|
guard, err := acquireSessionRemovalGuard(sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer guard.Release()
|
|
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
|
if info, err := os.Stat(itemDir); err == nil {
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("session trash target is not a directory: %s", key)
|
|
}
|
|
trashPath := filepath.Join(itemDir, key)
|
|
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
|
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !matches {
|
|
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
} else if os.IsNotExist(err) {
|
|
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
return err
|
|
}
|
|
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
|
|
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
|
return err
|
|
}
|
|
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
|
return err
|
|
}
|
|
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
|
b, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return agent.ClearCleanupPending(sessionPath)
|
|
}
|
|
|
|
func validateSessionTrashTarget(dir, sessionPath, key string) error {
|
|
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
|
if info, err := os.Stat(itemDir); err == nil {
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("session trash target is not a directory: %s", key)
|
|
}
|
|
trashPath := filepath.Join(itemDir, key)
|
|
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
|
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if removable {
|
|
return nil
|
|
}
|
|
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
|
|
return errSessionBusyElsewhere
|
|
}
|
|
return nil
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type preparedSessionTrashTarget struct {
|
|
shouldMove bool
|
|
itemDir string
|
|
allocateUnique bool
|
|
}
|
|
|
|
func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
|
|
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
|
|
return preparedSessionTrashTarget{}, nil
|
|
} else if err != nil {
|
|
return preparedSessionTrashTarget{}, err
|
|
}
|
|
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
|
if info, err := os.Stat(itemDir); err == nil {
|
|
if !info.IsDir() {
|
|
return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
|
|
}
|
|
trashPath := filepath.Join(itemDir, key)
|
|
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
|
|
removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
|
|
if err != nil {
|
|
return preparedSessionTrashTarget{}, err
|
|
}
|
|
if removable {
|
|
return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
|
|
}
|
|
if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
|
|
return preparedSessionTrashTarget{}, errSessionBusyElsewhere
|
|
}
|
|
return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
return preparedSessionTrashTarget{}, err
|
|
}
|
|
if err := os.RemoveAll(itemDir); err != nil {
|
|
return preparedSessionTrashTarget{}, err
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return preparedSessionTrashTarget{}, err
|
|
}
|
|
return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
|
|
}
|
|
|
|
func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
|
|
root := sessionTrashPath(dir)
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
stem := strings.TrimSuffix(key, ".jsonl")
|
|
for i := 0; i < 100; i++ {
|
|
name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
|
|
itemDir := filepath.Join(root, name)
|
|
if err := os.Mkdir(itemDir, 0o755); err == nil {
|
|
return itemDir, nil
|
|
} else if !os.IsExist(err) {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
|
|
}
|
|
|
|
// liveSessionRemovableWithExistingTrash reports whether a live session file may
|
|
// be removed even though a trash copy already exists under the same key: the
|
|
// live file must be discardable (empty stub) or byte-identical to the trash
|
|
// copy, and no other runtime may hold its session lease — another process could
|
|
// be mid-write, and removing the file would silently drop its next save.
|
|
func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
|
|
discardable, err := liveSessionDiscardable(sessionPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
duplicate := false
|
|
if !discardable {
|
|
duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if !discardable && !duplicate {
|
|
return false, nil
|
|
}
|
|
return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
|
|
}
|
|
|
|
func liveSessionDiscardable(sessionPath string) (bool, error) {
|
|
if agent.IsCleanupPending(sessionPath) {
|
|
return true, nil
|
|
}
|
|
info, err := os.Stat(sessionPath)
|
|
if os.IsNotExist(err) {
|
|
return true, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if info.IsDir() {
|
|
return false, nil
|
|
}
|
|
if info.Size() == 0 {
|
|
return true, nil
|
|
}
|
|
session, err := agent.LoadSession(sessionPath)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return !session.HasContent(), nil
|
|
}
|
|
|
|
func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
|
|
if _, err := os.Stat(sessionPath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return true, nil
|
|
}
|
|
return false, err
|
|
}
|
|
// Compare decoded transcripts, not .jsonl bytes: the checkpoint only
|
|
// changes at checkpoints, so two byte-identical .jsonl files can hide
|
|
// diverged event logs — and treating them as duplicates would delete the
|
|
// live session's newer history.
|
|
return agent.SessionsShareContent(sessionPath, trashPath)
|
|
}
|
|
|
|
func sessionFileHasConversationContent(sessionPath string) bool {
|
|
if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
|
|
return false
|
|
}
|
|
info, err := os.Stat(sessionPath)
|
|
if err != nil || info.IsDir() || info.Size() == 0 {
|
|
return false
|
|
}
|
|
session, err := agent.LoadSession(sessionPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return session.HasContent()
|
|
}
|
|
|
|
func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove func()) error {
|
|
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
|
|
return err
|
|
}
|
|
target, err := prepareSessionTrashTarget(dir, sessionPath, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !target.shouldMove {
|
|
return nil
|
|
}
|
|
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
|
|
// there takes its own removal guard, and the guard is not reentrant.
|
|
guard, err := acquireSessionRemovalGuard(sessionPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer guard.Release()
|
|
if err := invalidateTopicDirMarkers(dir); err != nil {
|
|
return err
|
|
}
|
|
itemDir := target.itemDir
|
|
if target.allocateUnique {
|
|
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if beforeMove != nil {
|
|
beforeMove()
|
|
}
|
|
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
|
|
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
|
|
return err
|
|
}
|
|
if err := guard.RemoveSidecarsAndRelease(); err != nil {
|
|
return err
|
|
}
|
|
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
|
b, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := agent.ClearCleanupPending(sessionPath); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func listTrashedSessionFiles(dir string) ([]string, error) {
|
|
root := sessionTrashPath(dir)
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []string{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
paths := []string{}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
itemDir := filepath.Join(root, e.Name())
|
|
keys := []string{}
|
|
if b, err := readFileUTF8(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
|
|
var meta trashedSessionMeta
|
|
if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
|
|
keys = append(keys, meta.Key)
|
|
}
|
|
}
|
|
if store.IsSessionTranscriptName(e.Name()) {
|
|
keys = append(keys, e.Name())
|
|
}
|
|
for _, key := range keys {
|
|
path := filepath.Join(itemDir, key)
|
|
validPath, _, _, err := validateTrashedSessionPath(dir, path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
|
|
paths = append(paths, validPath)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func trashedSessionDeletedAt(path string) int64 {
|
|
b, err := readFileUTF8(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var meta trashedSessionMeta
|
|
if err := json.Unmarshal(b, &meta); err != nil {
|
|
return 0
|
|
}
|
|
return meta.DeletedAt
|
|
}
|
|
|
|
func restoreTrashedSessionFile(dir, path string) error {
|
|
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dir, key)
|
|
if _, err := os.Stat(target); err == nil {
|
|
discardable, err := liveSessionDiscardable(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !discardable {
|
|
return fmt.Errorf("session already exists: %s", key)
|
|
}
|
|
if err := removeDesktopSessionArtifacts(target); err != nil {
|
|
return err
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := checkRestoreSubagentConflicts(dir, itemDir); err != nil {
|
|
return err
|
|
}
|
|
for _, artifact := range sessionTrashArtifacts(target, key) {
|
|
if err := movePathIfExists(filepath.Join(itemDir, artifact.name), artifact.src); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := restoreSubagentArtifacts(dir, itemDir); err != nil {
|
|
return err
|
|
}
|
|
return os.RemoveAll(itemDir)
|
|
}
|
|
|
|
func purgeTrashedSessionFile(dir, path string) error {
|
|
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.RemoveAll(itemDir); err != nil {
|
|
return err
|
|
}
|
|
m, err := loadSessionTitlesForUpdate(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, ok := m[key]; ok {
|
|
delete(m, key)
|
|
if err := saveSessionTitles(dir, m); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := removeSessionDisplayKey(dir, key); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func movePathIfExists(src, dst string) error {
|
|
if _, err := os.Lstat(src); os.IsNotExist(err) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Try os.Rename first — it's atomic and fast when it works.
|
|
if err := os.Rename(src, dst); err == nil {
|
|
return nil
|
|
} else if sourcePathMissing(src) {
|
|
return nil
|
|
} else if !isRenameCrossDeviceOrBusy(err) {
|
|
return err
|
|
}
|
|
// Fallback: copy then remove. This handles cross-device moves and the
|
|
// Windows case where a directory rename fails because a handle is briefly
|
|
// held open (e.g. antivirus scan, indexing, or a just-closed file).
|
|
return copyAndRemove(src, dst)
|
|
}
|
|
|
|
// isRenameCrossDeviceOrBusy reports whether err is a cross-device rename or
|
|
// a "file busy" error that a copy+remove fallback can recover from.
|
|
func isRenameCrossDeviceOrBusy(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
// Cross-device link.
|
|
if le, ok := err.(*os.LinkError); ok {
|
|
if le.Err == syscall.EXDEV {
|
|
return true
|
|
}
|
|
// Windows: "The process cannot access the file because it is being used by another process."
|
|
if errno, ok := le.Err.(syscall.Errno); ok {
|
|
return errno == 32 // ERROR_SHARING_VIOLATION
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sourcePathMissing(src string) bool {
|
|
if strings.TrimSpace(src) == "" {
|
|
return true
|
|
}
|
|
_, err := os.Lstat(src)
|
|
return os.IsNotExist(err)
|
|
}
|
|
|
|
// copyPathFn is a seam for tests to simulate a source vanishing mid-copy.
|
|
var copyPathFn = copyPath
|
|
|
|
// copyAndRemove recursively copies src to dst, then removes src. Used as a
|
|
// fallback when os.Rename fails (cross-device or Windows file-lock races).
|
|
func copyAndRemove(src, dst string) error {
|
|
if err := copyPathFn(src, dst); err != nil {
|
|
if sourcePathMissing(src) {
|
|
// The source vanished mid-copy; drop the partial destination so
|
|
// the trash never keeps a truncated artifact that a later restore
|
|
// would resurrect as a corrupted transcript.
|
|
_ = os.RemoveAll(dst)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
// On Windows, wait briefly for any file handle release.
|
|
time.Sleep(10 * time.Millisecond)
|
|
return os.RemoveAll(src)
|
|
}
|
|
|
|
func copyPath(src, dst string) error {
|
|
info, err := os.Lstat(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
mode := info.Mode()
|
|
switch {
|
|
case mode&os.ModeSymlink != 0:
|
|
return copySymlink(src, dst)
|
|
case mode.IsDir():
|
|
return copyDir(src, dst, mode.Perm())
|
|
case mode.IsRegular():
|
|
return copyFile(src, dst, mode.Perm())
|
|
default:
|
|
return fmt.Errorf("unsupported file type in rename fallback: %s", src)
|
|
}
|
|
}
|
|
|
|
func copyDir(src, dst string, mode os.FileMode) error {
|
|
if err := os.MkdirAll(dst, mode); err != nil {
|
|
return err
|
|
}
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
_ = os.RemoveAll(dst)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
srcPath := filepath.Join(src, e.Name())
|
|
dstPath := filepath.Join(dst, e.Name())
|
|
if err := copyPath(srcPath, dstPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyFile(src, dst string, mode os.FileMode) error {
|
|
// Open source file.
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
// Create destination file.
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
in.Close()
|
|
return err
|
|
}
|
|
// Copy content.
|
|
_, err = io.Copy(out, in)
|
|
// Close both files before any removal.
|
|
closeErr := out.Close()
|
|
in.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copySymlink(src, dst string) error {
|
|
target, err := os.Readlink(src)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return os.Symlink(target, dst)
|
|
}
|
|
|
|
func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
|
|
artifacts, err := agent.ListSubagentsByParent(dir, agent.BranchID(sessionPath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trashSubagentDir := filepath.Join(itemDir, "subagents")
|
|
for _, artifact := range artifacts {
|
|
paths := []string{artifact.SessionPath, artifact.MetaPath}
|
|
paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
|
|
for _, src := range paths {
|
|
if strings.TrimSpace(src) == "" {
|
|
continue
|
|
}
|
|
if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkRestoreSubagentConflicts(dir, itemDir string) error {
|
|
trashSubagentDir := filepath.Join(itemDir, "subagents")
|
|
entries, err := os.ReadDir(trashSubagentDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
target := filepath.Join(dir, "subagents", entry.Name())
|
|
if _, err := os.Stat(target); err == nil {
|
|
return fmt.Errorf("subagent artifact already exists: %s", entry.Name())
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func restoreSubagentArtifacts(dir, itemDir string) error {
|
|
trashSubagentDir := filepath.Join(itemDir, "subagents")
|
|
entries, err := os.ReadDir(trashSubagentDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if err := movePathIfExists(filepath.Join(trashSubagentDir, entry.Name()), filepath.Join(dir, "subagents", entry.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSessionPath(dir, sessionPath string) (string, string, error) {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return "", "", fmt.Errorf("empty session path")
|
|
}
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
path := sessionPath
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(absDir, path)
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
|
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
|
}
|
|
rel, err := filepath.Rel(absDir, absPath)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
|
return "", "", fmt.Errorf("session path outside session dir: %s", sessionPath)
|
|
}
|
|
if info, err := os.Lstat(absPath); err == nil {
|
|
if info.IsDir() {
|
|
return "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
|
}
|
|
realDir, dirErr := filepath.EvalSymlinks(absDir)
|
|
if dirErr != nil {
|
|
realDir = absDir
|
|
}
|
|
realPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
rel, err := filepath.Rel(realDir, realPath)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
|
return "", "", fmt.Errorf("session path escapes session dir: %s", sessionPath)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return "", "", err
|
|
}
|
|
return absPath, filepath.Base(absPath), nil
|
|
}
|
|
|
|
func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return "", "", "", fmt.Errorf("empty session path")
|
|
}
|
|
root, err := filepath.Abs(sessionTrashPath(dir))
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
path := sessionPath
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(root, path)
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
|
|
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
|
}
|
|
rel, err := filepath.Rel(root, absPath)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
|
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
|
|
}
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) != 2 {
|
|
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
|
}
|
|
if parts[0] != parts[1] {
|
|
b, err := readFileUTF8(filepath.Join(root, parts[0], sessionTrashMetaFile))
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
|
}
|
|
var meta trashedSessionMeta
|
|
if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
|
|
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
|
}
|
|
}
|
|
if info, err := os.Lstat(absPath); err == nil {
|
|
if info.IsDir() {
|
|
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
|
}
|
|
realRoot, dirErr := filepath.EvalSymlinks(root)
|
|
if dirErr != nil {
|
|
realRoot = root
|
|
}
|
|
realPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
rel, err := filepath.Rel(realRoot, realPath)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
|
return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return "", "", "", err
|
|
}
|
|
return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
|
|
}
|
|
|
|
type sessionDisplayMap map[string]map[string]string
|
|
|
|
type sessionPlannerDisplayMap map[string][]plannerDisplayTurn
|
|
|
|
type plannerDisplayTurn struct {
|
|
UserHash string `json:"userHash"`
|
|
Messages []HistoryMessage `json:"messages"`
|
|
}
|
|
|
|
func messageDisplayKey(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return fmt.Sprintf("%x", sum[:])
|
|
}
|
|
|
|
func loadSessionDisplays(dir string) sessionDisplayMap {
|
|
m := sessionDisplayMap{}
|
|
b, err := readFileUTF8(sessionDisplayPath(dir))
|
|
if err != nil {
|
|
return m
|
|
}
|
|
_ = json.Unmarshal(b, &m)
|
|
return m
|
|
}
|
|
|
|
func sessionPlannerDisplayPath(dir string) string {
|
|
return filepath.Join(dir, sessionPlannerDisplayFile)
|
|
}
|
|
|
|
func loadSessionPlannerDisplays(dir string) sessionPlannerDisplayMap {
|
|
m := sessionPlannerDisplayMap{}
|
|
if strings.TrimSpace(dir) == "" {
|
|
return m
|
|
}
|
|
b, err := readFileUTF8(sessionPlannerDisplayPath(dir))
|
|
if err != nil {
|
|
return m
|
|
}
|
|
_ = json.Unmarshal(b, &m)
|
|
return m
|
|
}
|
|
|
|
func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
|
|
b, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".planner-display.*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return fileutil.ReplaceFile(tmpPath, sessionPlannerDisplayPath(dir))
|
|
}
|
|
|
|
func recordSessionPlannerDisplay(dir, sessionPath, userContent string, messages []HistoryMessage) error {
|
|
if strings.TrimSpace(sessionPath) == "" || strings.TrimSpace(userContent) == "" || len(messages) == 0 {
|
|
return nil
|
|
}
|
|
m := loadSessionPlannerDisplays(dir)
|
|
key := filepath.Base(sessionPath)
|
|
turn := plannerDisplayTurn{
|
|
UserHash: messageDisplayKey(userContent),
|
|
Messages: cloneHistoryMessages(messages),
|
|
}
|
|
m[key] = append(m[key], turn)
|
|
return saveSessionPlannerDisplays(dir, m)
|
|
}
|
|
|
|
func sessionPlannerDisplayTurns(dir, sessionPath string) []plannerDisplayTurn {
|
|
if strings.TrimSpace(dir) == "" || strings.TrimSpace(sessionPath) == "" {
|
|
return nil
|
|
}
|
|
turns := loadSessionPlannerDisplays(dir)[filepath.Base(sessionPath)]
|
|
if len(turns) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]plannerDisplayTurn, 0, len(turns))
|
|
for _, turn := range turns {
|
|
if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, plannerDisplayTurn{
|
|
UserHash: turn.UserHash,
|
|
Messages: cloneHistoryMessages(turn.Messages),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func saveSessionDisplays(dir string, m sessionDisplayMap) error {
|
|
b, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".display.*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
|
|
}
|
|
|
|
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
|
|
if len(m) == 0 {
|
|
err := os.Remove(sessionDisplayPath(dir))
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return saveSessionDisplays(dir, m)
|
|
}
|
|
|
|
func removeSessionDisplayKey(dir, key string) error {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
m := loadSessionDisplays(dir)
|
|
if m[key] == nil {
|
|
return nil
|
|
}
|
|
delete(m, key)
|
|
return saveOrRemoveSessionDisplays(dir, m)
|
|
}
|
|
|
|
func removeSessionDisplay(dir, sessionPath string) error {
|
|
if strings.TrimSpace(sessionPath) == "" {
|
|
return nil
|
|
}
|
|
return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
|
|
}
|
|
|
|
func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
|
|
m := loadSessionDisplays(dir)
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
changed := false
|
|
for key := range m {
|
|
if sessionDisplayKeyStillOwned(dir, key, protected) {
|
|
continue
|
|
}
|
|
delete(m, key)
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
return saveOrRemoveSessionDisplays(dir, m)
|
|
}
|
|
|
|
func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
|
|
return false
|
|
}
|
|
if protected != nil {
|
|
if _, ok := protected[key]; ok {
|
|
return true
|
|
}
|
|
}
|
|
sessionPath := filepath.Join(dir, key)
|
|
if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
|
|
return true
|
|
}
|
|
trashPath := filepath.Join(sessionTrashPath(dir), key, key)
|
|
if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
|
|
return true
|
|
}
|
|
if paths, err := listTrashedSessionFiles(dir); err == nil {
|
|
for _, path := range paths {
|
|
if filepath.Base(path) == key {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func recordSessionDisplay(dir, sessionPath, content, display string) error {
|
|
if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
|
|
return nil
|
|
}
|
|
m := loadSessionDisplays(dir)
|
|
key := filepath.Base(sessionPath)
|
|
if m[key] == nil {
|
|
m[key] = map[string]string{}
|
|
}
|
|
m[key][messageDisplayKey(content)] = display
|
|
return saveSessionDisplays(dir, m)
|
|
}
|
|
|
|
// sessionDisplayResolver loads the sidecar once and returns a per-message
|
|
// resolver, so a transcript of N messages doesn't re-read .display.json N times.
|
|
func sessionDisplayResolver(dir, sessionPath string) func(content string) string {
|
|
return sessionDisplayResolverFromMap(loadSessionDisplays(dir), sessionPath)
|
|
}
|
|
|
|
func sessionDisplayResolverFromMap(displays sessionDisplayMap, sessionPath string) func(content string) string {
|
|
byHash := displays[filepath.Base(sessionPath)]
|
|
return func(content string) string {
|
|
if byHash != nil {
|
|
if display := byHash[messageDisplayKey(content)]; strings.TrimSpace(display) != "" {
|
|
return display
|
|
}
|
|
}
|
|
return control.StripComposePrefixes(content)
|
|
}
|
|
}
|
|
|
|
func resolveSessionDisplay(dir, sessionPath, content string) string {
|
|
return sessionDisplayResolver(dir, sessionPath)(content)
|
|
}
|