返回 DeepSeek-Reasonix
export_snapshot.go
根目录 / internal / session / export_snapshot.go
1 package session
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "reasonix/internal/projectiondb"
9 "strings"
10 "time"
11 )
12
13 // ExportSnapshot names an immutable display-history cut, not a provider workset.
14 // It is safe to retain while the source runtime continues accepting events.
15 type ExportSnapshot struct {
16 ReadIncomplete bool `json:"readIncomplete,omitempty"`
17 Ref SessionRef `json:"session"`
18 StorageGeneration string `json:"storageGeneration"`
19 SnapshotSequence uint64 `json:"snapshotSequence"`
20 AcceptedThrough uint64 `json:"acceptedThrough"`
21 DurableThrough uint64 `json:"durableThrough"`
22 CapturedAt time.Time `json:"capturedAt"`
23 Title string `json:"title"`
24 }
25
26 func (q *Query) CaptureExportSnapshot(ctx context.Context, ref SessionRef) (ExportSnapshot, error) {
27 out := ExportSnapshot{Ref: ref, CapturedAt: time.Now().UTC()}
28 if err := ref.validate(q.hostID); err != nil {
29 return out, err
30 }
31 generation := q.storageGeneration(ref.SessionID)
32 if generation == "" {
33 return out, ErrSessionNotFound
34 }
35 live := false
36 if q.service != nil {
37 if runtime, ok := q.service.Runtime(ref); ok {
38 live = true
39 out.AcceptedThrough = runtime.Session().EventSequence()
40 receipt, err := runtime.Session().FlushThrough(ctx, out.AcceptedThrough)
41 if err != nil {
42 return out, err
43 }
44 out.DurableThrough = receipt.DurableSequence
45 }
46 }
47 _, path, err := q.prepareHistoryIndex(ctx, ref)
48 if err != nil {
49 return out, err
50 }
51 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{Path: path, Migrations: historyMigrations, RequireDisk: true, MaxOpenConns: 1})
52 if err != nil {
53 return out, err
54 }
55 defer handle.DB.Close()
56 metadata, err := readHistoryIndexMetadata(ctx, handle.DB)
57 if err != nil {
58 return out, err
59 }
60 if q.storageGeneration(ref.SessionID) != generation {
61 return out, ErrStaleGeneration
62 }
63 if !live {
64 out.AcceptedThrough = metadata.durableSequence
65 out.DurableThrough = metadata.durableSequence
66 }
67 out.SnapshotSequence = out.AcceptedThrough
68 out.StorageGeneration = generation
69 if out.SnapshotSequence > metadata.durableSequence {
70 return out, fmt.Errorf("session: history does not cover export watermark")
71 }
72 if metadata.generation != fmt.Sprintf("%s:%d", generation, metadata.viewSequence) {
73 return out, ErrStaleGeneration
74 }
75 info, err := q.Stat(ctx, ref)
76 if err != nil {
77 return out, err
78 }
79 out.Title = info.Title
80 return out, nil
81 }
82
83 // VisitExportMessages traverses the existing versioned display projection in
84 // ascending order, without accumulating the transcript or growing a UI window.
85 func (q *Query) VisitExportMessages(ctx context.Context, snapshot ExportSnapshot, visit func(PersistentMessage) error) error {
86 return q.VisitExportMessagesForRef(ctx, snapshot.Ref, snapshot, visit)
87 }
88
89 // VisitExportMessagesForRef keeps the trusted storage identity separate from
90 // snapshot metadata received over a transport boundary.
91 func (q *Query) VisitExportMessagesForRef(ctx context.Context, ref SessionRef, snapshot ExportSnapshot, visit func(PersistentMessage) error) error {
92 if err := q.ValidateExportSourceForRef(ref, snapshot); err != nil {
93 return err
94 }
95 generation := q.storageGeneration(ref.SessionID)
96 filesystem, path, err := q.prepareHistoryIndex(ctx, ref)
97 if err != nil {
98 return err
99 }
100 handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{Path: path, Migrations: historyMigrations, RequireDisk: true, MaxOpenConns: 1})
101 if err != nil {
102 return err
103 }
104 defer handle.DB.Close()
105 metadata, err := readHistoryIndexMetadata(ctx, handle.DB)
106 if err != nil {
107 return err
108 }
109 if q.storageGeneration(ref.SessionID) != generation || metadata.durableSequence < snapshot.SnapshotSequence {
110 return ErrStaleGeneration
111 }
112 if metadata.generation != fmt.Sprintf("%s:%d", generation, metadata.viewSequence) {
113 return ErrStaleGeneration
114 }
115 boundary := int64(1)
116 for {
117 if err := ctx.Err(); err != nil {
118 return err
119 }
120 if q.storageGeneration(ref.SessionID) != generation {
121 return ErrStaleGeneration
122 }
123 page, err := q.readHistoryWindowPage(ctx, handle.DB, filesystem, ref, metadata, snapshot.SnapshotSequence, boundary, historyWindowDirNewer, 100)
124 if err != nil {
125 return err
126 }
127 if err = q.attachHistoryWindowTurnStats(ctx, handle.DB, &page); err != nil {
128 return err
129 }
130 if err = q.attachToolObservations(ctx, handle.DB, ref, &page); err != nil {
131 return err
132 }
133 for _, message := range page.Messages {
134 if message.ContentRef != nil {
135 contentRef := *message.ContentRef
136 body := make([]byte, 0)
137 for offset := int64(0); offset < contentRef.Bytes; {
138 chunk, err := q.ReadContent(ctx, ref, contentRef, offset, min(int64(1<<20), contentRef.Bytes-offset))
139 if err != nil {
140 return err
141 }
142 if len(chunk) == 0 {
143 return fmt.Errorf("session: empty export content chunk")
144 }
145 body = append(body, chunk...)
146 offset += int64(len(chunk))
147 }
148 if !json.Valid(body) {
149 return fmt.Errorf("session: invalid export message")
150 }
151 message.Inline = body
152 message.ContentRef = nil
153 }
154 if err := visit(message); err != nil {
155 return err
156 }
157 }
158 if !page.HasNewer {
159 if q.storageGeneration(ref.SessionID) != generation {
160 return ErrStaleGeneration
161 }
162 return nil
163 }
164 if len(page.Messages) == 0 {
165 return fmt.Errorf("session: export cursor did not advance")
166 }
167 boundary = page.Messages[len(page.Messages)-1].Position + 1
168 }
169 }
170
171 // CaptureDiagnosticSnapshot records an accepted boundary without requiring a
172 // successful persistence checkpoint or a readable display projection.
173 func (q *Query) CaptureDiagnosticSnapshot(ctx context.Context, ref SessionRef) (ExportSnapshot, error) {
174 out := ExportSnapshot{Ref: ref, CapturedAt: time.Now().UTC()}
175 if err := ref.validate(q.hostID); err != nil {
176 return out, err
177 }
178 out.StorageGeneration = q.storageGeneration(ref.SessionID)
179 if out.StorageGeneration == "" {
180 return out, ErrSessionNotFound
181 }
182 if q.service != nil {
183 if runtime, ok := q.service.Runtime(ref); ok {
184 state := runtime.Session().StateSnapshot()
185 out.AcceptedThrough, out.DurableThrough = state.EventSequence, state.DurableSequence
186 out.SnapshotSequence = out.AcceptedThrough
187 return out, nil
188 }
189 }
190 err := q.StreamSession(ctx, ref, func(commit Commit) error {
191 out.DurableThrough = commit.LastSequence()
192 return nil
193 })
194 out.AcceptedThrough, out.SnapshotSequence = out.DurableThrough, out.DurableThrough
195 out.ReadIncomplete = err != nil
196 // A damaged tail must not prevent exporting its readable prefix and errors.
197 if err != nil && ctx.Err() != nil {
198 return out, ctx.Err()
199 }
200 return out, nil
201 }
202
203 // ValidateExportSource never rebinds a handle to replacement storage.
204 func (q *Query) ValidateExportSource(snapshot ExportSnapshot) error {
205 return q.ValidateExportSourceForRef(snapshot.Ref, snapshot)
206 }
207
208 // ValidateExportSourceForRef validates transport metadata against a trusted
209 // canonical identity without using request-owned fields as path components.
210 func (q *Query) ValidateExportSourceForRef(ref SessionRef, snapshot ExportSnapshot) error {
211 if err := ref.validate(q.hostID); err != nil {
212 return err
213 }
214 if snapshot.Ref != ref {
215 return ErrStaleGeneration
216 }
217 generation := q.storageGeneration(ref.SessionID)
218 if generation == "" {
219 return ErrSessionNotFound
220 }
221 if snapshot.StorageGeneration != generation && !strings.HasPrefix(snapshot.StorageGeneration, generation+":") {
222 return ErrStaleGeneration
223 }
224 return nil
225 }
226
227 // StreamExportCommits visits a fixed raw prefix for cold diagnostics. Reopening
228 // that session in another tab cannot make traversal chase new commits forever.
229 func (q *Query) StreamExportCommits(ctx context.Context, snapshot ExportSnapshot, visit func(Commit) error) error {
230 if err := q.ValidateExportSource(snapshot); err != nil {
231 return err
232 }
233 reached := errors.New("export boundary reached")
234 err := q.StreamSession(ctx, snapshot.Ref, func(commit Commit) error {
235 if commit.LastSequence() > snapshot.SnapshotSequence {
236 return reached
237 }
238 if err := visit(commit); err != nil {
239 return err
240 }
241 if commit.LastSequence() == snapshot.SnapshotSequence {
242 return reached
243 }
244 return nil
245 })
246 if errors.Is(err, reached) {
247 err = nil
248 }
249 if err != nil {
250 return err
251 }
252 return q.ValidateExportSource(snapshot)
253 }
254
254 lines GO