返回 DeepSeek-Reasonix
session_display_read_model.go
根目录 / internal / agent / session_display_read_model.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "time"
10
11 "reasonix/internal/provider"
12 "reasonix/internal/store"
13 )
14
15 // ErrSessionDisplayReadModelDamaged means the authoritative event log has a
16 // torn tail. The normal session save path owns healing it; a derived display
17 // read model must never publish the replayable prefix as if it were complete.
18 var ErrSessionDisplayReadModelDamaged = errors.New("session display read model source is damaged")
19
20 // LoadSessionDisplayMessages returns the authoritative persisted transcript
21 // without applying resume-time normalization. Desktop history uses this only
22 // as the bounded-recovery path while the random-read model is missing or stale.
23 func LoadSessionDisplayMessages(path string) ([]provider.Message, PersistedState, bool, error) {
24 unlock := lockSessionSavePath(path)
25 defer unlock()
26 return loadSessionDisplayMessagesUnlocked(path)
27 }
28
29 func loadSessionDisplayMessagesUnlocked(path string) ([]provider.Message, PersistedState, bool, error) {
30 return loadSessionDisplayMessagesContextUnlocked(context.Background(), path)
31 }
32
33 func loadSessionDisplayMessagesContextUnlocked(ctx context.Context, path string) ([]provider.Message, PersistedState, bool, error) {
34 hasher := newSessionTranscriptHasher()
35 msgs, _, damaged, err := loadSessionMessagesWithContext(ctx, path, defaultSessionReplayLimits, hasher)
36 if err != nil {
37 return nil, PersistedState{}, false, err
38 }
39 digest, digestOK := hasher.sum()
40 if !digestOK {
41 digest, err = digestSessionMessages(msgs)
42 if err != nil {
43 return nil, PersistedState{}, false, err
44 }
45 }
46 revision, ledgerDigest, err := sessionContentRevision(path)
47 if err != nil {
48 return nil, PersistedState{}, false, err
49 }
50 state := PersistedState{
51 Digest: digest,
52 DigestHex: digestString(digest),
53 }
54 if ledgerDigest != "" && ledgerDigest == state.DigestHex {
55 state.Revision = revision
56 state.RevisionKnown = true
57 }
58 return msgs, state, !damaged, nil
59 }
60
61 // RepairSessionDisplayReadModel atomically refreshes the compatibility JSONL
62 // read model and publishes its display index from the authoritative event log.
63 // It shares both save locks with Session.save, so a background migration can
64 // never interleave between an event append and its revision/index publication.
65 func RepairSessionDisplayReadModel(path string) error {
66 if path == "" {
67 return fmt.Errorf("empty session path")
68 }
69 unlock := lockSessionSavePath(path)
70 defer unlock()
71 unlockFile, err := lockSessionFile(path)
72 if err != nil {
73 return fmt.Errorf("lock session file: %w", err)
74 }
75 defer unlockFile()
76
77 msgs, state, repairable, err := loadSessionDisplayMessagesUnlocked(path)
78 if err != nil {
79 return err
80 }
81 if !repairable {
82 return ErrSessionDisplayReadModelDamaged
83 }
84 if err := writeSessionMessages(path, msgs); err != nil {
85 return fmt.Errorf("write session display read model: %w", err)
86 }
87 idx := BuildSessionDisplayIndex(msgs, state.Revision, state.RevisionKnown, state.Digest)
88 if idx == nil {
89 return fmt.Errorf("encode session display index")
90 }
91 if err := WriteSessionDisplayIndex(store.SessionDisplayIndex(path), idx); err != nil {
92 return err
93 }
94 return nil
95 }
96
97 // appendSessionDisplayReadModel advances the JSONL random-read model in place
98 // when the previous display index proves it is exactly the authoritative
99 // prefix. false,nil asks the caller to leave the old model/index untouched and
100 // let background repair rebuild them; it never guesses from file size alone.
101 func appendSessionDisplayReadModel(path string, msgs []provider.Message, appendFrom int, baseRevision int64) (bool, error) {
102 if appendFrom <= 0 || appendFrom > len(msgs) {
103 return false, nil
104 }
105 indexPath := store.SessionDisplayIndex(path)
106 idx, err := LoadSessionDisplayIndex(indexPath)
107 if err != nil || idx.MessageCount != appendFrom || !idx.RevisionKnown || idx.Revision != baseRevision {
108 return false, nil
109 }
110 info, err := os.Stat(path)
111 if err != nil || info.IsDir() || idx.TranscriptSize != info.Size() {
112 return false, nil
113 }
114 indexInfo, err := os.Stat(indexPath)
115 if err != nil || indexInfo.IsDir() || !indexInfo.ModTime().After(info.ModTime()) {
116 return false, nil
117 }
118
119 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
120 if err != nil {
121 return false, err
122 }
123 originalSize := info.Size()
124 rollback := func(cause error) (bool, error) {
125 closeErr := f.Close()
126 truncateErr := os.Truncate(path, originalSize)
127 return false, errors.Join(cause, closeErr, truncateErr)
128 }
129 enc := json.NewEncoder(f)
130 for i := appendFrom; i < len(msgs); i++ {
131 if err := enc.Encode(msgs[i]); err != nil {
132 return rollback(fmt.Errorf("encode session display message %d: %w", i, err))
133 }
134 }
135 if err := f.Sync(); err != nil {
136 return rollback(fmt.Errorf("sync session display read model: %w", err))
137 }
138 if err := f.Close(); err != nil {
139 if truncateErr := os.Truncate(path, originalSize); truncateErr != nil {
140 return false, errors.Join(err, truncateErr)
141 }
142 return false, err
143 }
144 // Some filesystems expose coarse mtimes. Ensure the subsequently-published
145 // index cannot appear older than this append generation.
146 _ = os.Chtimes(path, time.Now(), time.Now())
147 return true, nil
148 }
149
149 lines GO