| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | |
| 6 | "reasonix/internal/event" |
| 7 | "reasonix/internal/eventwire" |
| 8 | ) |
| 9 | |
| 10 | func (p *Projection) PersistenceFailed() { |
| 11 | p.mu.Lock() |
| 12 | defer p.mu.Unlock() |
| 13 | p.runtime.Status = event.TurnRecoveryRequired |
| 14 | p.revision++ |
| 15 | p.publishChangeLocked(Change{Event: &eventwire.Event{Kind: "turn_status", Status: string(event.TurnRecoveryRequired)}}) |
| 16 | p.revision++ |
| 17 | p.publishChangeLocked(Change{Event: &eventwire.Event{Kind: "notice", Level: "warn", Code: "transcript_save_failed", Text: "Session output could not be saved. Displayed output is retained; recovery is required."}}) |
| 18 | } |
| 19 | |
| 20 | func (p *Projection) RestoreRuntime(runtime Runtime, durable uint64) { |
| 21 | p.mu.Lock() |
| 22 | defer p.mu.Unlock() |
| 23 | p.runtime, p.durable = runtime, durable |
| 24 | } |
| 25 | |
| 26 | // AcceptBusiness advances coverage for every accepted batch, including batches |
| 27 | // that have no visible records. Rows come from the canonical message reader, |
| 28 | // never from a wire-event ledger. Mutable stream owners are retained separately |
| 29 | // from the bounded settled tail. |
| 30 | func (p *Projection) AcceptBusiness(rows []Message, covered uint64, turnID string, rewrite bool, finalMessageID ...string) { |
| 31 | p.acceptBusiness(rows, nil, covered, turnID, rewrite, finalMessageID...) |
| 32 | } |
| 33 | |
| 34 | // AcceptRetractions invalidates the reading cut while retaining unrelated |
| 35 | // active output. The canonical reader supplies the new visible history. |
| 36 | func (p *Projection) AcceptRetractions(rows []Message, removed []string, covered uint64, turnID, finalMessageID string) { |
| 37 | p.acceptBusiness(rows, removed, covered, turnID, false, finalMessageID) |
| 38 | } |
| 39 | |
| 40 | func (p *Projection) acceptBusiness(rows []Message, removed []string, covered uint64, turnID string, rewrite bool, finalMessageID ...string) { |
| 41 | p.mu.Lock() |
| 42 | defer p.mu.Unlock() |
| 43 | if covered <= p.covered { |
| 44 | return |
| 45 | } |
| 46 | if rewrite { |
| 47 | p.buffer.Reset() |
| 48 | } |
| 49 | for _, id := range removed { |
| 50 | for index, row := range slices.Backward(p.buffer.messages) { |
| 51 | if row.message.MessageID == id { |
| 52 | if row.message.Role == "user" { |
| 53 | p.buffer.userTurns-- |
| 54 | } |
| 55 | p.buffer.messages = append(p.buffer.messages[:index], p.buffer.messages[index+1:]...) |
| 56 | } |
| 57 | } |
| 58 | delete(p.buffer.byMessageID, id) |
| 59 | delete(p.results, id) |
| 60 | for key, attempt := range p.attempts { |
| 61 | if attempt.MessageID == id { |
| 62 | delete(p.attempts, key) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | reset := rewrite || len(removed) > 0 |
| 67 | if reset { |
| 68 | p.identity.RewriteEpoch++ |
| 69 | clear(p.snapshots) |
| 70 | p.snapshotOrder = nil |
| 71 | p.snapshotBytes = 0 |
| 72 | } |
| 73 | if p.buffer.byMessageID == nil { |
| 74 | p.buffer.byMessageID = make(map[string]*bufferedMessage) |
| 75 | } |
| 76 | if p.results == nil { |
| 77 | p.results = make(map[string]uint64) |
| 78 | } |
| 79 | published := make([]Message, 0, len(rows)) |
| 80 | for _, message := range rows { |
| 81 | p.ensureRecordIdentity(&message) |
| 82 | published = append(published, message) |
| 83 | if message.TurnID == "" { |
| 84 | message.TurnID = turnID |
| 85 | } |
| 86 | var row *bufferedMessage |
| 87 | for _, existing := range p.buffer.messages { |
| 88 | if existing.message.RecordID == message.RecordID { |
| 89 | row = existing |
| 90 | break |
| 91 | } |
| 92 | } |
| 93 | if row == nil { |
| 94 | row = &bufferedMessage{} |
| 95 | p.buffer.messages = append(p.buffer.messages, row) |
| 96 | if message.Role == "user" { |
| 97 | p.buffer.userTurns++ |
| 98 | } |
| 99 | } |
| 100 | row.message = message |
| 101 | if message.Role == "assistant" { |
| 102 | if len(finalMessageID) == 0 { |
| 103 | p.runtime.FinalMessageID = message.MessageID |
| 104 | } |
| 105 | row.content.replace(message.Content) |
| 106 | row.reasoning.replace(message.Reasoning) |
| 107 | row.message.Content, row.message.Reasoning = "", "" |
| 108 | } |
| 109 | if message.MessageID != "" && (message.Role == "assistant" || message.Role == "user") { |
| 110 | p.buffer.byMessageID[message.MessageID] = row |
| 111 | p.results[message.MessageID] = covered |
| 112 | } |
| 113 | } |
| 114 | first := p.covered + 1 |
| 115 | if len(finalMessageID) > 0 { |
| 116 | p.runtime.FinalMessageID = finalMessageID[0] |
| 117 | } |
| 118 | p.covered = covered |
| 119 | p.revision++ |
| 120 | p.trimSettledLocked() |
| 121 | p.publishChangeLocked(Change{FirstSeq: first, Records: published, ResetRequired: reset}) |
| 122 | } |
| 123 | |
| 124 | func (p *Projection) trimSettledLocked() { |
| 125 | const retainedRecords = 96 |
| 126 | if len(p.buffer.messages) <= retainedRecords { |
| 127 | return |
| 128 | } |
| 129 | keep := make([]*bufferedMessage, 0, retainedRecords+len(p.attempts)) |
| 130 | for i, row := range p.buffer.messages { |
| 131 | active := row.message.Pending |
| 132 | for _, attempt := range p.attempts { |
| 133 | active = active || attempt.MessageID == row.message.MessageID |
| 134 | } |
| 135 | if active || i >= len(p.buffer.messages)-retainedRecords { |
| 136 | keep = append(keep, row) |
| 137 | } else if p.buffer.byMessageID[row.message.MessageID] == row { |
| 138 | delete(p.buffer.byMessageID, row.message.MessageID) |
| 139 | delete(p.results, row.message.MessageID) |
| 140 | } |
| 141 | } |
| 142 | p.buffer.messages = keep |
| 143 | } |
| 144 |