| 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 |