| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "os" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/store" |
| 12 | ) |
| 13 | |
| 14 | // LoadSessionPreviewFromDisplayIndex reads only the first authored user-message |
| 15 | // range from a current display index. It never acquires the session save lock or |
| 16 | // scans the full transcript, so runtime tree snapshots cannot wait behind a |
| 17 | // long-running save. A missing or stale index fails closed and lets the caller |
| 18 | // use another preview source. |
| 19 | func LoadSessionPreviewFromDisplayIndex(path string) (string, bool, error) { |
| 20 | path = strings.TrimSpace(path) |
| 21 | if path == "" { |
| 22 | return "", false, fmt.Errorf("empty session path") |
| 23 | } |
| 24 | index, err := LoadSessionDisplayIndex(store.SessionDisplayIndex(path)) |
| 25 | if err != nil { |
| 26 | return "", false, err |
| 27 | } |
| 28 | messageIndex := -1 |
| 29 | for _, entry := range index.Entries { |
| 30 | if entry.StartsTurn { |
| 31 | messageIndex = entry.Index |
| 32 | break |
| 33 | } |
| 34 | } |
| 35 | if messageIndex < 0 { |
| 36 | return "", false, nil |
| 37 | } |
| 38 | messages, checkedIndex, err := LoadSessionDisplayMessageRange(path, messageIndex, messageIndex+1) |
| 39 | if err != nil { |
| 40 | return "", false, err |
| 41 | } |
| 42 | if checkedIndex == nil || messageIndex >= len(checkedIndex.Entries) || !checkedIndex.Entries[messageIndex].StartsTurn { |
| 43 | return "", false, fmt.Errorf("session display index changed while reading preview") |
| 44 | } |
| 45 | if err := validateSessionPreviewDisplayIndex(path, checkedIndex); err != nil { |
| 46 | return "", false, err |
| 47 | } |
| 48 | preview, turns := SessionPreviewFromMessages(messages) |
| 49 | if turns == 0 || strings.TrimSpace(preview) == "" { |
| 50 | return "", false, nil |
| 51 | } |
| 52 | return preview, true, nil |
| 53 | } |
| 54 | |
| 55 | func validateSessionPreviewDisplayIndex(path string, index *SessionDisplayIndex) error { |
| 56 | transcriptInfo, err := os.Stat(path) |
| 57 | if err != nil { |
| 58 | return err |
| 59 | } |
| 60 | indexInfo, err := os.Stat(store.SessionDisplayIndex(path)) |
| 61 | if err != nil { |
| 62 | return err |
| 63 | } |
| 64 | // The index is published after the content it describes. Equality is |
| 65 | // ambiguous on coarse filesystems, so this latency-sensitive fallback |
| 66 | // declines to run a full digest scan to resolve the tie. |
| 67 | if !indexInfo.ModTime().After(SessionContentModTime(path)) { |
| 68 | return fmt.Errorf("session display index is not newer than session content") |
| 69 | } |
| 70 | identity, identityKnown, err := SessionContentIdentity(path) |
| 71 | if err != nil { |
| 72 | return err |
| 73 | } |
| 74 | if identityKnown { |
| 75 | if !ValidateSessionDisplayIndex(index, identity.Revision, identity.RevisionKnown, identity.Digest, transcriptInfo.Size()) { |
| 76 | return fmt.Errorf("session display index does not match content identity") |
| 77 | } |
| 78 | } else if index.RevisionKnown { |
| 79 | return fmt.Errorf("session display index has no matching content identity") |
| 80 | } |
| 81 | return nil |
| 82 | } |
| 83 | |
| 84 | // LoadSessionDisplayMessageRange decodes a bounded range through the display |
| 85 | // index. Callers must separately compare the index revision and digest with |
| 86 | // the authoritative content ledger before trusting it. |
| 87 | func LoadSessionDisplayMessageRange(path string, start, end int) ([]provider.Message, *SessionDisplayIndex, error) { |
| 88 | index, err := LoadSessionDisplayIndex(store.SessionDisplayIndex(path)) |
| 89 | if err != nil { |
| 90 | return nil, nil, err |
| 91 | } |
| 92 | if start < 0 || end < start || end > index.MessageCount { |
| 93 | return nil, index, fmt.Errorf("invalid display message range [%d,%d)", start, end) |
| 94 | } |
| 95 | f, err := os.Open(path) |
| 96 | if err != nil { |
| 97 | return nil, index, err |
| 98 | } |
| 99 | defer f.Close() |
| 100 | info, err := f.Stat() |
| 101 | if err != nil { |
| 102 | return nil, index, err |
| 103 | } |
| 104 | if info.Size() != index.TranscriptSize { |
| 105 | return nil, index, fmt.Errorf("display index transcript size changed") |
| 106 | } |
| 107 | out := make([]provider.Message, 0, end-start) |
| 108 | for _, entry := range index.Entries[start:end] { |
| 109 | line := make([]byte, entry.Length) |
| 110 | if _, err := f.ReadAt(line, entry.Offset); err != nil && err != io.EOF { |
| 111 | return nil, index, err |
| 112 | } |
| 113 | if len(line) == 0 || line[len(line)-1] != '\n' { |
| 114 | return nil, index, fmt.Errorf("display message %d is not newline terminated", entry.Index) |
| 115 | } |
| 116 | var message provider.Message |
| 117 | if err := json.Unmarshal(line[:len(line)-1], &message); err != nil { |
| 118 | return nil, index, fmt.Errorf("decode display message %d: %w", entry.Index, err) |
| 119 | } |
| 120 | out = append(out, message) |
| 121 | } |
| 122 | return out, index, nil |
| 123 | } |
| 124 |