返回 DeepSeek-Reasonix
projection.go
根目录 / internal / transcript / projection.go
1 package transcript
2
3 import (
4 "crypto/rand"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "slices"
9 "sort"
10 "sync"
11
12 "reasonix/internal/event"
13 "reasonix/internal/eventwire"
14 "reasonix/internal/turnevent"
15 )
16
17 const ProtocolVersion = 1
18
19 type Identity struct {
20 SessionID string `json:"sessionId"`
21 HeadID string `json:"headId"`
22 RewriteEpoch uint64 `json:"rewriteEpoch"`
23 RuntimeEpoch string `json:"runtimeEpoch"`
24 }
25
26 type ActiveAttempt struct {
27 ID string `json:"id"`
28 MessageID string `json:"messageId"`
29 TurnID string `json:"turnId"`
30 NextIndex uint64 `json:"nextIndex"`
31 }
32
33 // Runtime is reduced from the same ordered events as the visible records.
34 // In particular it is never sampled independently after a history read.
35 type Runtime struct {
36 FinalMessageID string `json:"finalMessageId,omitempty"`
37 DurationMs int64 `json:"durationMs,omitempty"`
38 SamplingCount int `json:"samplingCount"`
39 ToolCount int `json:"toolCount"`
40 TurnID string `json:"turnId,omitempty"`
41 SubmissionID string `json:"submissionId,omitempty"`
42 Status event.TurnStatus `json:"status,omitempty"`
43 Phase string `json:"phase,omitempty"`
44 StartedAt int64 `json:"startedAt,omitempty"`
45 PendingEvents []eventwire.Event `json:"pendingEvents"`
46 CompletionSummary *eventwire.CompletionSummary `json:"completionSummary,omitempty"`
47 TurnUsage *TurnUsage `json:"turnUsage,omitempty"`
48 }
49
50 type Boundary struct {
51 ProtocolVersion int `json:"protocolVersion"`
52 SnapshotID string `json:"snapshotId"`
53 Identity Identity `json:"identity"`
54 ProjectionRevision uint64 `json:"projectionRevision"`
55 CoveredThroughSeq uint64 `json:"coveredThroughSeq"`
56 DurableSeq uint64 `json:"durableSeq"`
57 }
58
59 // Projection has one commit boundary for rows, runtime and coverage. All
60 // mutations happen after durable append and before publishing the event.
61 // It performs no callbacks or I/O under its mutex.
62 type Projection struct {
63 mu sync.Mutex
64 incarnation string
65 identity Identity
66 revision uint64
67 covered uint64
68 durable uint64
69 followers map[string]*follower
70 results map[string]uint64
71 buffer Buffer
72 runtime Runtime
73 startedTurnID string
74 attempts map[string]ActiveAttempt
75 toolCalls map[string]bool
76 prompts map[string]eventwire.Event
77 snapshots map[string]frozenSnapshot
78 snapshotOrder []string
79 snapshotBytes int
80 recordSerial uint64
81 // outline is the complete turn index of a frozen cut. It is built by
82 // freezeLocked and read only from frozen cuts, so it always describes the
83 // same revision as the records paged beside it.
84 outline []OutlineEntry
85 }
86
87 // ensureRecordIdentity owns the last-resort identity for display-only rows.
88 // Canonical messages retain their existing m:/tool: identities; transient
89 // frames without a business sequence receive an identity scoped to this
90 // projection incarnation and keep it for every later snapshot.
91 func (p *Projection) ensureRecordIdentity(message *Message) {
92 if message.RecordID != "" {
93 return
94 }
95 switch {
96 case message.Role == "tool" && message.ToolCallID != "":
97 message.RecordID = "tool:" + message.ToolCallID
98 case message.MessageID != "":
99 message.RecordID = "m:" + message.MessageID
100 default:
101 p.recordSerial++
102 message.RecordID = fmt.Sprintf("view:%s:%d", p.incarnation, p.recordSerial)
103 }
104 }
105
106 func (p *Projection) ensureBufferRecordIdentities() {
107 for _, row := range p.buffer.messages {
108 p.ensureRecordIdentity(&row.message)
109 }
110 }
111
112 func NewProjection(identity Identity, baseline []Message, covered uint64) (*Projection, error) {
113 p := &Projection{incarnation: rand.Text(), identity: identity, covered: covered, revision: 1,
114 attempts: make(map[string]ActiveAttempt), prompts: make(map[string]eventwire.Event)}
115 // Take ownership of nested metadata as well as the slice. Callers may
116 // reuse their conversion buffers immediately after construction.
117 encoded, err := json.Marshal(baseline)
118 if err != nil {
119 return nil, newBaselineError(err, "baseline_encode_failed", len(baseline), -1, -1, Message{})
120 }
121 var owned []Message
122 if err = json.Unmarshal(encoded, &owned); err != nil {
123 return nil, newBaselineError(err, "baseline_decode_failed", len(baseline), -1, -1, Message{})
124 }
125 p.buffer.byMessageID = make(map[string]*bufferedMessage)
126 seen := make(map[string]int)
127 for index, m := range owned {
128 if m.Role == "user" {
129 p.buffer.userTurns++
130 if m.HistoryTurn == 0 {
131 m.HistoryTurn = p.buffer.userTurns
132 }
133 }
134 if m.RecordID == "" {
135 switch {
136 case m.Role == "tool" && m.ToolCallID != "":
137 m.RecordID = "tool:" + m.ToolCallID
138 case m.MessageID != "":
139 m.RecordID = "m:" + m.MessageID
140 default:
141 return nil, newBaselineError(errors.New("transcript baseline has a record without identity"), "missing_record_identity", len(owned), index, -1, m)
142 }
143 }
144 if previous, exists := seen[m.RecordID]; exists {
145 return nil, newBaselineError(fmt.Errorf("duplicate transcript record %q", m.RecordID), "duplicate_record_identity", len(owned), index, previous, m)
146 }
147 seen[m.RecordID] = index
148 row := &bufferedMessage{message: m}
149 if m.Role == "assistant" {
150 row.content.replace(m.Content)
151 row.reasoning.replace(m.Reasoning)
152 row.message.Content, row.message.Reasoning = "", ""
153 }
154 p.buffer.messages = append(p.buffer.messages, row)
155 if m.MessageID != "" && (m.Role == "assistant" || m.Role == "user") {
156 p.buffer.byMessageID[m.MessageID] = row
157 }
158 }
159 return p, nil
160 }
161
162 func (p *Projection) Apply(envelope turnevent.Envelope) error {
163 p.mu.Lock()
164 defer p.mu.Unlock()
165 if envelope.SessionID != p.identity.SessionID || (envelope.RuntimeEpoch != "" && envelope.RuntimeEpoch != p.identity.RuntimeEpoch) {
166 return errors.New("transcript event identity mismatch")
167 }
168 if envelope.Sequence <= p.covered {
169 return nil
170 }
171 if envelope.Sequence != p.covered+1 {
172 return errors.New("transcript projection sequence gap")
173 }
174 return p.applyLocked(envelope, envelope.Sequence)
175 }
176
177 // ApplyFrame uses an independent display revision. The caller supplies the
178 // business cut; a token, phase or usage notification cannot allocate log sequence.
179 func (p *Projection) ApplyFrame(envelope turnevent.Envelope, covered uint64) error {
180 p.mu.Lock()
181 defer p.mu.Unlock()
182 if (envelope.SessionID != "" && envelope.SessionID != p.identity.SessionID) || (envelope.RuntimeEpoch != "" && envelope.RuntimeEpoch != p.identity.RuntimeEpoch) {
183 return errors.New("transcript event identity mismatch")
184 }
185 if covered < p.covered {
186 return errors.New("transcript business coverage regression")
187 }
188 if err := p.applyLocked(envelope, covered); err != nil {
189 return err
190 }
191 p.trimSettledLocked()
192 return nil
193 }
194
195 func (p *Projection) applyLocked(envelope turnevent.Envelope, covered uint64) error {
196 // Detach pointer payloads before retaining them. Event publication cannot
197 // mutate a previously committed snapshot through an aliased tool slice.
198 encoded, err := json.Marshal(envelope)
199 if err != nil {
200 return err
201 }
202 var owned turnevent.Envelope
203 if err = json.Unmarshal(encoded, &owned); err != nil {
204 return err
205 }
206 if e, ok := EventFromEnvelope(owned); ok {
207 p.buffer.Apply(e)
208 p.ensureBufferRecordIdentities()
209 if e.Kind == event.TurnDone {
210 p.applyTerminalNotices(e)
211 }
212 if m := p.buffer.byMessageID[e.MessageID]; m != nil {
213 if m.message.CreatedAt == 0 {
214 m.message.CreatedAt = owned.CreatedAt
215 }
216 if owned.SubmissionID != "" {
217 m.message.SubmissionID = owned.SubmissionID
218 }
219 }
220 }
221 p.applyRuntimeLocked(owned)
222 w := owned.Event
223 p.covered = covered
224 p.revision++
225 // Legacy ledger numbering is never a chat coverage cursor.
226 w.Sequence = 0
227 state := p.runtime
228 change := Change{Event: &w, Runtime: &state}
229 if owned.Kind == "text" || owned.Kind == "reasoning" || owned.Kind == "tool_call_delta" || (owned.Kind == "tool_dispatch" && w.Tool != nil && w.Tool.Partial) {
230 if attempt, ok := p.attempts[w.AttemptID]; ok {
231 change.AttemptID, change.Index = attempt.ID, attempt.NextIndex
232 attempt.NextIndex++
233 p.attempts[attempt.ID] = attempt
234 }
235 }
236 if owned.Kind == "stream_attempt" && w.StreamAttempt != nil && w.StreamAttempt.Action == "commit" {
237 change.AttemptID, change.ResultSeq = w.StreamAttempt.ID, p.results[w.MessageID]
238 change.ResultKind = "message/complete"
239 if w.StreamAttempt.Reason == "interrupted" {
240 change.ResultKind = "message/interrupted"
241 }
242 change.ResetRequired = change.ResultSeq == 0
243 }
244 p.publishChangeLocked(change)
245 return nil
246 }
247
248 func mergeTurnUsage(current *TurnUsage, usage *eventwire.Usage) *TurnUsage {
249 if usage == nil {
250 return current
251 }
252 if current == nil {
253 zero := 0
254 current = &TurnUsage{CacheReadTokens: &zero, ReasoningTokens: &zero}
255 }
256 current.UncachedInputTokens += usage.CacheMissTokens
257 if usage.CacheMissTokens == 0 && usage.CacheHitTokens == 0 {
258 current.UncachedInputTokens += usage.PromptTokens
259 }
260 current.OutputTokens += usage.CompletionTokens
261 requestTotal := usage.TotalTokens
262 if requestTotal <= 0 {
263 requestTotal = usage.PromptTokens + usage.CompletionTokens
264 }
265 current.TotalTokens += requestTotal
266 cacheRead := valueOrZero(current.CacheReadTokens) + usage.CacheHitTokens
267 current.CacheReadTokens = &cacheRead
268 reasoning := valueOrZero(current.ReasoningTokens) + usage.ReasoningTokens
269 current.ReasoningTokens = &reasoning
270 if usage.CostQuote != nil && usage.CostQuote.ModelRef != "" {
271 if !slices.Contains(current.Routes, usage.CostQuote.ModelRef) {
272 current.Routes = append(current.Routes, usage.CostQuote.ModelRef)
273 }
274 }
275 return current
276 }
277
278 func valueOrZero(value *int) int {
279 if value == nil {
280 return 0
281 }
282 return *value
283 }
284
285 // SetRuntimeEpoch is called only by the controller's idle routing boundary.
286 // Existing rows survive a runtime rebind; outstanding snapshot leases do not.
287 func (p *Projection) SetRuntimeEpoch(epoch string) {
288 p.mu.Lock()
289 defer p.mu.Unlock()
290 if p.identity.RuntimeEpoch != epoch {
291 p.identity.RuntimeEpoch = epoch
292 p.incarnation = rand.Text()
293 p.revision++
294 for _, f := range p.followers {
295 f.reset = true
296 select {
297 case f.wake <- struct{}{}:
298 default:
299 }
300 }
301 clear(p.followers)
302 }
303 }
304
305 func (p *Projection) boundaryLocked() Boundary {
306 return Boundary{ProtocolVersion: ProtocolVersion,
307 SnapshotID: fmt.Sprintf("%s:%d", p.incarnation, p.revision),
308 Identity: p.identity, ProjectionRevision: p.revision, CoveredThroughSeq: p.covered, DurableSeq: p.durable}
309 }
310
311 func (p *Projection) Boundary() Boundary {
312 p.mu.Lock()
313 defer p.mu.Unlock()
314 return p.boundaryLocked()
315 }
316
317 func (p *Projection) runtimeLocked() (Runtime, []ActiveAttempt) {
318 runtime := p.runtime
319 runtime.PendingEvents = make([]eventwire.Event, 0, len(p.prompts))
320 keys := make([]string, 0, len(p.prompts))
321 for key := range p.prompts {
322 keys = append(keys, key)
323 }
324 sort.Strings(keys)
325 for _, key := range keys {
326 runtime.PendingEvents = append(runtime.PendingEvents, p.prompts[key])
327 }
328 attempts := make([]ActiveAttempt, 0, len(p.attempts))
329 for _, attempt := range p.attempts {
330 attempts = append(attempts, attempt)
331 }
332 sort.Slice(attempts, func(i, j int) bool { return attempts[i].ID < attempts[j].ID })
333 return runtime, attempts
334 }
335
336 func (p *Projection) applyRuntimeLocked(owned turnevent.Envelope) {
337 w := owned.Event
338 if owned.TurnID != "" {
339 p.runtime.TurnID, p.runtime.Status = owned.TurnID, owned.Status
340 p.runtime.SubmissionID = owned.SubmissionID
341 }
342 switch owned.Kind {
343 case "turn_started":
344 p.runtime.FinalMessageID, p.runtime.DurationMs = "", 0
345 p.runtime.SamplingCount, p.runtime.ToolCount = 0, 0
346 p.toolCalls = make(map[string]bool)
347 p.retireRecoveryNotices()
348 if p.startedTurnID != owned.TurnID || p.runtime.StartedAt == 0 {
349 p.runtime.StartedAt = owned.CreatedAt
350 p.startedTurnID = owned.TurnID
351 }
352 p.runtime.Phase = ""
353 p.runtime.CompletionSummary = nil
354 p.runtime.TurnUsage = nil
355 p.buffer.completion = nil
356 case "usage":
357 p.runtime.TurnUsage = mergeTurnUsage(p.runtime.TurnUsage, w.Usage)
358 case "turn_phase":
359 p.runtime.Phase = w.Phase
360 case "completion_summary":
361 p.runtime.CompletionSummary = w.Completion
362 case "stream_attempt":
363 if w.StreamAttempt != nil {
364 if w.StreamAttempt.Action == "begin" {
365 p.runtime.SamplingCount++
366 p.attempts[w.StreamAttempt.ID] = ActiveAttempt{ID: w.StreamAttempt.ID, MessageID: w.MessageID, TurnID: owned.TurnID}
367 } else {
368 delete(p.attempts, w.StreamAttempt.ID)
369 }
370 }
371 case "tool_dispatch":
372 if w.Tool != nil && w.Tool.ID != "" && !p.toolCalls[w.Tool.ID] {
373 if p.toolCalls == nil {
374 p.toolCalls = make(map[string]bool)
375 }
376 p.toolCalls[w.Tool.ID] = true
377 p.runtime.ToolCount++
378 }
379 case "ask_request", "approval_request", "mcp_interaction":
380 id := w.PromptID
381 if id == "" {
382 id = owned.ItemID
383 }
384 if id != "" {
385 p.prompts[id] = w
386 }
387 case "prompt_answered":
388 delete(p.prompts, owned.ItemID)
389 case "turn_done":
390 durationMs := int64(0)
391 if p.runtime.StartedAt > 0 && owned.CreatedAt >= p.runtime.StartedAt {
392 durationMs = owned.CreatedAt - p.runtime.StartedAt
393 }
394 p.runtime.DurationMs = durationMs
395 p.buffer.attachTurnStats(owned.TurnID, p.runtime.TurnUsage, durationMs, owned.CreatedAt, p.runtime.FinalMessageID)
396 clear(p.prompts)
397 clear(p.attempts)
398 if owned.TranscriptDigest != "" {
399 p.identity.HeadID = owned.HeadID
400 p.identity.RewriteEpoch = owned.RewriteEpoch
401 }
402 }
403 }
404
404 lines GO