| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "reflect" |
| 7 | "unicode/utf8" |
| 8 | ) |
| 9 | |
| 10 | const ( |
| 11 | // MaxResponseBytes includes the complete encoded response and its newline. |
| 12 | MaxResponseBytes = 2 << 20 |
| 13 | defaultPageRecords = 120 |
| 14 | maxPageRecords = 1000 |
| 15 | defaultPageBytes = 512 << 10 |
| 16 | maxPageBytes = MaxResponseBytes |
| 17 | inlineFieldBytes = 64 << 10 |
| 18 | previewBytes = 4 << 10 |
| 19 | contentChunkBytes = 64 << 10 |
| 20 | ) |
| 21 | |
| 22 | type PageRequest struct { |
| 23 | SnapshotID string `json:"snapshotId"` |
| 24 | Before int `json:"before"` |
| 25 | Records int `json:"records"` |
| 26 | Bytes int `json:"bytes"` |
| 27 | } |
| 28 | |
| 29 | type ContentRef struct { |
| 30 | SnapshotID string `json:"snapshotId"` |
| 31 | RecordID string `json:"recordId"` |
| 32 | Path []string `json:"path"` |
| 33 | Bytes int `json:"bytes"` |
| 34 | } |
| 35 | |
| 36 | type Record struct { |
| 37 | ID string `json:"id"` |
| 38 | Order int `json:"order"` |
| 39 | Message Message `json:"message"` |
| 40 | Refs []ContentRef `json:"refs"` |
| 41 | } |
| 42 | |
| 43 | type Snapshot struct { |
| 44 | Boundary |
| 45 | Records []Record `json:"records"` |
| 46 | Runtime Runtime `json:"runtime"` |
| 47 | ActiveAttempts []ActiveAttempt `json:"activeAttempts"` |
| 48 | ActiveRecords []Record `json:"activeRecords"` |
| 49 | Before int `json:"before"` |
| 50 | HasOlder bool `json:"hasOlder"` |
| 51 | TotalRecords int `json:"totalRecords"` |
| 52 | TotalTurns int `json:"totalTurns"` |
| 53 | Stale bool `json:"stale"` |
| 54 | } |
| 55 | |
| 56 | type ContentRequest struct { |
| 57 | ContentRef |
| 58 | Offset int `json:"offset"` |
| 59 | } |
| 60 | |
| 61 | type ContentChunk struct { |
| 62 | Data string `json:"data"` |
| 63 | NextOffset int `json:"nextOffset"` |
| 64 | Done bool `json:"done"` |
| 65 | Stale bool `json:"stale"` |
| 66 | } |
| 67 | |
| 68 | // Snapshot returns a bounded immutable window. Pages and content refs are |
| 69 | // bound to this exact revision. A bounded cache retains immutable cuts while |
| 70 | // streaming continues; an evicted cut returns Stale rather than mixed history. |
| 71 | func (p *Projection) Snapshot(req PageRequest) (Snapshot, error) { |
| 72 | p.mu.Lock() |
| 73 | defer p.mu.Unlock() |
| 74 | frozen, err := p.freezeLocked(req.SnapshotID) |
| 75 | if err != nil { |
| 76 | return Snapshot{}, err |
| 77 | } |
| 78 | if frozen == nil { |
| 79 | return Snapshot{Boundary: p.boundaryLocked(), Stale: true}, nil |
| 80 | } |
| 81 | return frozen.snapshotCurrent(req) |
| 82 | } |
| 83 | |
| 84 | func (p *Projection) snapshotCurrent(req PageRequest) (Snapshot, error) { |
| 85 | out := Snapshot{Boundary: p.boundaryLocked(), Records: []Record{}, ActiveAttempts: []ActiveAttempt{}, ActiveRecords: []Record{}} |
| 86 | if req.SnapshotID != "" && req.SnapshotID != out.SnapshotID { |
| 87 | out.Stale = true |
| 88 | return out, nil |
| 89 | } |
| 90 | limit := req.Records |
| 91 | if limit <= 0 { |
| 92 | limit = defaultPageRecords |
| 93 | } |
| 94 | limit = min(limit, maxPageRecords) |
| 95 | budget := req.Bytes |
| 96 | if budget <= 0 { |
| 97 | budget = defaultPageBytes |
| 98 | } |
| 99 | budget = min(budget, maxPageBytes) |
| 100 | end := len(p.buffer.messages) |
| 101 | if req.SnapshotID != "" { |
| 102 | end = min(max(req.Before, 0), end) |
| 103 | } |
| 104 | out.TotalRecords = len(p.buffer.messages) |
| 105 | out.TotalTurns = p.buffer.userTurns |
| 106 | used := 0 |
| 107 | for i := end - 1; i >= 0 && len(out.Records) < limit; i-- { |
| 108 | row, err := boundedRecord(p.buffer.messages[i].materialize(), out.SnapshotID) |
| 109 | if err != nil { |
| 110 | return Snapshot{}, err |
| 111 | } |
| 112 | row.Order = i |
| 113 | encoded, err := json.Marshal(row) |
| 114 | if err != nil { |
| 115 | return Snapshot{}, err |
| 116 | } |
| 117 | if len(encoded) > maxPageBytes { |
| 118 | return Snapshot{}, errors.New("transcript record metadata exceeds the page limit") |
| 119 | } |
| 120 | if len(out.Records) > 0 && used+len(encoded) > budget { |
| 121 | break |
| 122 | } |
| 123 | out.Records = append(out.Records, row) |
| 124 | used += len(encoded) |
| 125 | } |
| 126 | for i, j := 0, len(out.Records)-1; i < j; i, j = i+1, j-1 { |
| 127 | out.Records[i], out.Records[j] = out.Records[j], out.Records[i] |
| 128 | } |
| 129 | out.Before = end - len(out.Records) |
| 130 | out.HasOlder = out.Before > 0 |
| 131 | runtime, attempts := p.runtimeLocked() |
| 132 | // Detach nested prompt pointers before releasing the mutex. |
| 133 | encoded, err := json.Marshal(runtime) |
| 134 | if err != nil { |
| 135 | return Snapshot{}, err |
| 136 | } |
| 137 | if err = json.Unmarshal(encoded, &out.Runtime); err != nil { |
| 138 | return Snapshot{}, err |
| 139 | } |
| 140 | out.ActiveAttempts = attempts |
| 141 | // A running assistant can precede a large batch of tool result rows. Keep |
| 142 | // every still-mutable owner in the same snapshot cut so a later delta can |
| 143 | // never be appended to an unloaded prefix. |
| 144 | { |
| 145 | present := make(map[string]struct{}, len(out.Records)) |
| 146 | for _, record := range out.Records { |
| 147 | present[record.ID] = struct{}{} |
| 148 | } |
| 149 | for _, i := range activeRecordIndexes(p.buffer.messages, out.Before, runtime) { |
| 150 | m := p.buffer.messages[i].materialize() |
| 151 | active := (!runtime.Status.Terminal() && m.Pending) || (m.Role == "user" && m.TurnID == runtime.TurnID && runtime.TurnID != "") |
| 152 | for _, call := range m.ToolCalls { |
| 153 | active = active || (!runtime.Status.Terminal() && call.Pending) |
| 154 | } |
| 155 | if !active { |
| 156 | continue |
| 157 | } |
| 158 | row, err := boundedRecord(m, out.SnapshotID) |
| 159 | if err != nil { |
| 160 | return Snapshot{}, err |
| 161 | } |
| 162 | row.Order = i |
| 163 | if _, alreadyPresent := present[row.ID]; alreadyPresent { |
| 164 | continue |
| 165 | } |
| 166 | present[row.ID] = struct{}{} |
| 167 | out.ActiveRecords = append(out.ActiveRecords, row) |
| 168 | } |
| 169 | } |
| 170 | encoded, err = json.Marshal(out) |
| 171 | if err != nil { |
| 172 | return Snapshot{}, err |
| 173 | } |
| 174 | if len(encoded)+1 > maxPageBytes { |
| 175 | return Snapshot{}, errors.New("transcript snapshot metadata exceeds the page limit") |
| 176 | } |
| 177 | return out, nil |
| 178 | } |
| 179 | |
| 180 | // activeRecordIndexes walks only the current mutable turn. Older settled |
| 181 | // history cannot gain a streamed suffix, so scanning it on every page request |
| 182 | // only extends the projection mutex hold time with the total transcript size. |
| 183 | // A missing runtime turn ID keeps the conservative full-prefix behavior for |
| 184 | // legacy baselines that do not carry turn identity. |
| 185 | func activeRecordIndexes(messages []*bufferedMessage, before int, runtime Runtime) []int { |
| 186 | if runtime.Status.Terminal() { |
| 187 | return nil |
| 188 | } |
| 189 | if runtime.TurnID == "" { |
| 190 | indexes := make([]int, 0, before) |
| 191 | for i := range before { |
| 192 | indexes = append(indexes, i) |
| 193 | } |
| 194 | return indexes |
| 195 | } |
| 196 | indexes := make([]int, 0, 8) |
| 197 | seenCurrentTurn := false |
| 198 | for i := before - 1; i >= 0; i-- { |
| 199 | m := messages[i].materialize() |
| 200 | isCurrentTurn := m.TurnID == runtime.TurnID |
| 201 | active := (!runtime.Status.Terminal() && m.Pending) || (m.Role == "user" && isCurrentTurn) |
| 202 | for _, call := range m.ToolCalls { |
| 203 | active = active || (!runtime.Status.Terminal() && call.Pending) |
| 204 | } |
| 205 | if active { |
| 206 | indexes = append(indexes, i) |
| 207 | } |
| 208 | if isCurrentTurn { |
| 209 | seenCurrentTurn = true |
| 210 | continue |
| 211 | } |
| 212 | if seenCurrentTurn { |
| 213 | break |
| 214 | } |
| 215 | } |
| 216 | return indexes |
| 217 | } |
| 218 | |
| 219 | func (p *Projection) Content(req ContentRequest) (ContentChunk, error) { |
| 220 | p.mu.Lock() |
| 221 | defer p.mu.Unlock() |
| 222 | if req.SnapshotID == "" { |
| 223 | return ContentChunk{}, errors.New("snapshotId is required") |
| 224 | } |
| 225 | frozen, err := p.freezeLocked(req.SnapshotID) |
| 226 | if err != nil { |
| 227 | return ContentChunk{}, err |
| 228 | } |
| 229 | if frozen == nil { |
| 230 | return ContentChunk{Stale: true}, nil |
| 231 | } |
| 232 | return frozen.contentCurrent(req) |
| 233 | } |
| 234 | |
| 235 | func (p *Projection) contentCurrent(req ContentRequest) (ContentChunk, error) { |
| 236 | if req.SnapshotID != p.boundaryLocked().SnapshotID { |
| 237 | return ContentChunk{Stale: true}, nil |
| 238 | } |
| 239 | if req.Offset < 0 || len(req.Path) == 0 || len(req.Path) > 16 { |
| 240 | return ContentChunk{}, errors.New("invalid transcript content request") |
| 241 | } |
| 242 | for _, row := range p.buffer.messages { |
| 243 | if row.message.RecordID != req.RecordID { |
| 244 | continue |
| 245 | } |
| 246 | text, ok := contentStringAt(row.materialize(), req.Path) |
| 247 | if !ok || req.Offset > len(text) || (req.Offset < len(text) && !utf8.RuneStart(text[req.Offset])) { |
| 248 | return ContentChunk{}, errors.New("invalid transcript content offset") |
| 249 | } |
| 250 | end := runeBoundary(text, min(req.Offset+contentChunkBytes, len(text))) |
| 251 | return ContentChunk{Data: text[req.Offset:end], NextOffset: end, Done: end == len(text)}, nil |
| 252 | } |
| 253 | return ContentChunk{}, errors.New("transcript record not found") |
| 254 | } |
| 255 | |
| 256 | func boundedRecord(message Message, snapshotID string) (Record, error) { |
| 257 | if message.RecordID == "" { |
| 258 | return Record{}, errors.New("transcript snapshot record identity is missing") |
| 259 | } |
| 260 | out := Record{ID: message.RecordID, Refs: []ContentRef{}} |
| 261 | out.Message = mapContentStrings(reflect.ValueOf(message), nil, func(text string, path []string) string { |
| 262 | if len(text) <= inlineFieldBytes { |
| 263 | return text |
| 264 | } |
| 265 | out.Refs = append(out.Refs, ContentRef{SnapshotID: snapshotID, RecordID: out.ID, Path: path, Bytes: len(text)}) |
| 266 | return text[:runeBoundary(text, previewBytes)] |
| 267 | }).Interface().(Message) |
| 268 | return out, nil |
| 269 | } |
| 270 | |
| 271 | func runeBoundary(text string, offset int) int { |
| 272 | for offset > 0 && offset < len(text) && !utf8.RuneStart(text[offset]) { |
| 273 | offset-- |
| 274 | } |
| 275 | return offset |
| 276 | } |
| 277 |